@rapidmx/web-client 0.37.1 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/configuration-encryption/index.tsx +17 -0
- package/apps/admin/domains/[uid].tsx +17 -4
- package/apps/admin/integrations/dns-providers/[uid].tsx +205 -0
- package/apps/admin/integrations/dns-providers/index.tsx +164 -0
- package/apps/admin/integrations/dns-providers/new/index.tsx +55 -0
- package/apps/admin/integrations/dns-resolver/index.tsx +16 -0
- package/apps/admin/integrations/giphy/index.tsx +16 -0
- package/apps/admin/integrations/index.tsx +31 -0
- package/apps/admin/integrations/plugin-registry/index.tsx +16 -0
- package/apps/admin/integrations/signing-ca/index.tsx +16 -0
- package/apps/admin/integrations/stun/index.tsx +16 -0
- package/apps/shared/components/admin/configEncryption/ConfigKeysManager.tsx +529 -0
- package/apps/shared/components/admin/configEncryption/keyringParts.ts +37 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +20 -4
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +23 -6
- package/apps/shared/components/admin/diagnostics/KeyringCard.tsx +144 -0
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +21 -6
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +15 -3
- package/apps/shared/components/admin/integrations/DnsProviderForm.tsx +354 -0
- package/apps/shared/components/admin/integrations/DnsProviderHosts.tsx +197 -0
- package/apps/shared/components/admin/integrations/DnsProviderOperations.tsx +115 -0
- package/apps/shared/components/admin/integrations/DnsResolverForm.tsx +61 -0
- package/apps/shared/components/admin/integrations/GiphyForm.tsx +59 -0
- package/apps/shared/components/admin/integrations/IntegrationSectionForm.tsx +234 -0
- package/apps/shared/components/admin/integrations/IntegrationSectionPage.tsx +40 -0
- package/apps/shared/components/admin/integrations/IntegrationsLayout.tsx +72 -0
- package/apps/shared/components/admin/integrations/PluginRegistryForm.tsx +230 -0
- package/apps/shared/components/admin/integrations/SecretField.tsx +91 -0
- package/apps/shared/components/admin/integrations/SigningCaForm.tsx +95 -0
- package/apps/shared/components/admin/integrations/StunForm.tsx +63 -0
- package/apps/shared/components/admin/integrations/dnsProviderParts.tsx +72 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +12 -1
- package/apps/shared/components/admin/settings/CopyableValue.tsx +16 -0
- package/apps/shared/components/admin/settings/DomainApexTxt.tsx +319 -0
- package/apps/shared/components/admin/settings/DomainDkimRotation.tsx +408 -0
- package/apps/shared/components/admin/settings/DomainDnsProvider.tsx +147 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +174 -17
- package/apps/shared/components/admin/setup/SetupWizard.tsx +39 -6
- package/dist/apps/admin/configuration-encryption/index.d.ts +4 -0
- package/dist/apps/admin/configuration-encryption/index.js +7 -0
- package/dist/apps/admin/domains/[uid].js +11 -4
- package/dist/apps/admin/integrations/dns-providers/[uid].d.ts +7 -0
- package/dist/apps/admin/integrations/dns-providers/[uid].js +117 -0
- package/dist/apps/admin/integrations/dns-providers/index.d.ts +3 -0
- package/dist/apps/admin/integrations/dns-providers/index.js +68 -0
- package/dist/apps/admin/integrations/dns-providers/new/index.d.ts +3 -0
- package/dist/apps/admin/integrations/dns-providers/new/index.js +31 -0
- package/dist/apps/admin/integrations/dns-resolver/index.d.ts +3 -0
- package/dist/apps/admin/integrations/dns-resolver/index.js +6 -0
- package/dist/apps/admin/integrations/giphy/index.d.ts +3 -0
- package/dist/apps/admin/integrations/giphy/index.js +6 -0
- package/dist/apps/admin/integrations/index.d.ts +4 -0
- package/dist/apps/admin/integrations/index.js +7 -0
- package/dist/apps/admin/integrations/plugin-registry/index.d.ts +3 -0
- package/dist/apps/admin/integrations/plugin-registry/index.js +6 -0
- package/dist/apps/admin/integrations/signing-ca/index.d.ts +3 -0
- package/dist/apps/admin/integrations/signing-ca/index.js +6 -0
- package/dist/apps/admin/integrations/stun/index.d.ts +3 -0
- package/dist/apps/admin/integrations/stun/index.js +6 -0
- package/dist/apps/shared/components/admin/configEncryption/ConfigKeysManager.d.ts +9 -0
- package/dist/apps/shared/components/admin/configEncryption/ConfigKeysManager.js +187 -0
- package/dist/apps/shared/components/admin/configEncryption/keyringParts.d.ts +10 -0
- package/dist/apps/shared/components/admin/configEncryption/keyringParts.js +26 -0
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +20 -4
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +8 -4
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +7 -5
- package/dist/apps/shared/components/admin/diagnostics/KeyringCard.d.ts +18 -0
- package/dist/apps/shared/components/admin/diagnostics/KeyringCard.js +51 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +9 -1
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +10 -3
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +13 -3
- package/dist/apps/shared/components/admin/integrations/DnsProviderForm.d.ts +17 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderForm.js +147 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderHosts.d.ts +10 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderHosts.js +101 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderOperations.d.ts +8 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderOperations.js +60 -0
- package/dist/apps/shared/components/admin/integrations/DnsResolverForm.d.ts +7 -0
- package/dist/apps/shared/components/admin/integrations/DnsResolverForm.js +15 -0
- package/dist/apps/shared/components/admin/integrations/GiphyForm.d.ts +7 -0
- package/dist/apps/shared/components/admin/integrations/GiphyForm.js +11 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationSectionForm.d.ts +54 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationSectionForm.js +131 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationSectionPage.d.ts +12 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationSectionPage.js +21 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationsLayout.d.ts +14 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationsLayout.js +46 -0
- package/dist/apps/shared/components/admin/integrations/PluginRegistryForm.d.ts +7 -0
- package/dist/apps/shared/components/admin/integrations/PluginRegistryForm.js +90 -0
- package/dist/apps/shared/components/admin/integrations/SecretField.d.ts +25 -0
- package/dist/apps/shared/components/admin/integrations/SecretField.js +25 -0
- package/dist/apps/shared/components/admin/integrations/SigningCaForm.d.ts +7 -0
- package/dist/apps/shared/components/admin/integrations/SigningCaForm.js +25 -0
- package/dist/apps/shared/components/admin/integrations/StunForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/integrations/StunForm.js +26 -0
- package/dist/apps/shared/components/admin/integrations/dnsProviderParts.d.ts +28 -0
- package/dist/apps/shared/components/admin/integrations/dnsProviderParts.js +43 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +8 -1
- package/dist/apps/shared/components/admin/settings/CopyableValue.d.ts +6 -0
- package/dist/apps/shared/components/admin/settings/CopyableValue.js +6 -0
- package/dist/apps/shared/components/admin/settings/DomainApexTxt.d.ts +13 -0
- package/dist/apps/shared/components/admin/settings/DomainApexTxt.js +148 -0
- package/dist/apps/shared/components/admin/settings/DomainDkimRotation.d.ts +15 -0
- package/dist/apps/shared/components/admin/settings/DomainDkimRotation.js +200 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsProvider.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsProvider.js +70 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +3 -3
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +61 -8
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +9 -1
- package/dist/lib/admin/configKeysApi.d.ts +96 -0
- package/dist/lib/admin/configKeysApi.js +34 -0
- package/dist/lib/admin/dnsProvidersApi.d.ts +172 -0
- package/dist/lib/admin/dnsProvidersApi.js +70 -0
- package/dist/lib/admin/domainsApi.d.ts +116 -1
- package/dist/lib/admin/domainsApi.js +33 -0
- package/dist/lib/admin/integrationsApi.d.ts +75 -0
- package/dist/lib/admin/integrationsApi.js +31 -0
- package/lib/admin/configKeysApi.ts +117 -0
- package/lib/admin/dnsProvidersApi.ts +241 -0
- package/lib/admin/domainsApi.ts +145 -1
- package/lib/admin/integrationsApi.ts +107 -0
- package/package.json +38 -37
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode, useEffect, useRef, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
cancelDkimRotation,
|
|
8
|
+
DkimRotationBlocker,
|
|
9
|
+
DkimRotationReason,
|
|
10
|
+
DkimRotationState,
|
|
11
|
+
DkimRotationView,
|
|
12
|
+
DnsRecordCheck,
|
|
13
|
+
Domain,
|
|
14
|
+
getDkimRotation,
|
|
15
|
+
rotateDkim,
|
|
16
|
+
setDkimRotationPaused,
|
|
17
|
+
} from "../../../../../lib/admin/domainsApi.js";
|
|
18
|
+
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
19
|
+
import Modal from "../../../../../lib/components/overlays/Modal.js";
|
|
20
|
+
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
21
|
+
import Alert from "../ActionAlert.js";
|
|
22
|
+
import { actionErrorMessage } from "../elevation.js";
|
|
23
|
+
import CopyableValue from "./CopyableValue.js";
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** How long a manual domain's new record may stay unpublished before the panel reminds the administrator (spec 6.2). */
|
|
28
|
+
const REMINDER_DAYS = 7;
|
|
29
|
+
|
|
30
|
+
/** Why a domain's key doesn't rotate: restapi's own refusals of "Rotate now" (`DKIM_ROTATION_REFUSALS`). */
|
|
31
|
+
const BLOCKER_MESSAGES: Record<DkimRotationBlocker, string> = {
|
|
32
|
+
unsupported: "This deployment's DKIM key provider doesn't rotate keys.",
|
|
33
|
+
ses: "Outbound mail is signed by Amazon SES's Easy DKIM, which rotates its own keys.",
|
|
34
|
+
no_key: "This domain has no DKIM key yet.",
|
|
35
|
+
imported: "This domain's DKIM key was imported by hand, so RapidMX can't rotate it.",
|
|
36
|
+
unknown_key: "The DKIM key provider can't tell whether it holds this domain's key, so it isn't rotated.",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const REASON_MESSAGES: Record<DkimRotationReason, string> = {
|
|
40
|
+
scheduled: "Started by the schedule.",
|
|
41
|
+
manual: "Started by an administrator.",
|
|
42
|
+
compromised: "Started because the key was reported compromised.",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Whole days from `iso` to `now`, never negative. */
|
|
46
|
+
function daysSince(iso: string, now: number): number {
|
|
47
|
+
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / DAY_MS));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatDays(days: number): string {
|
|
51
|
+
return days < 1 ? "less than a day" : days === 1 ? "1 day" : `${days} days`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatDate(iso: string): string {
|
|
55
|
+
return new Date(iso).toLocaleDateString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether the administrator adds `check`'s record by hand: the checklist says so, or says nothing about how it is published. */
|
|
59
|
+
function publishedByHand(check: DnsRecordCheck): boolean {
|
|
60
|
+
return !check.publication || check.publication === "manual";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The record to add at the DNS host: its name and value to copy, and whether the live check sees it. */
|
|
64
|
+
function RecordToPublish({ check, noun, showLive }: { check: DnsRecordCheck; noun: string; showLive: boolean }) {
|
|
65
|
+
return (
|
|
66
|
+
<dl className="grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm mb-3">
|
|
67
|
+
<dt className="text-text-muted py-2">Type</dt>
|
|
68
|
+
<dd className="py-2">{check.recordKind}</dd>
|
|
69
|
+
<dt className="text-text-muted py-2">Name / host</dt>
|
|
70
|
+
<dd className="bg-surface-alt border border-border rounded-sm py-1.5 px-3">
|
|
71
|
+
<CopyableValue value={check.recordName} copyLabel={`Copy name for the ${noun} record`} />
|
|
72
|
+
</dd>
|
|
73
|
+
<dt className="text-text-muted py-2">Value</dt>
|
|
74
|
+
<dd className="bg-surface-alt border border-border rounded-sm py-1.5 px-3">
|
|
75
|
+
<CopyableValue value={check.recommendedValue ?? ""} copyLabel={`Copy value for the ${noun} record`} />
|
|
76
|
+
</dd>
|
|
77
|
+
{showLive && (
|
|
78
|
+
<>
|
|
79
|
+
<dt className="text-text-muted py-2">Status</dt>
|
|
80
|
+
<dd className="py-2">
|
|
81
|
+
{check.matches ? (
|
|
82
|
+
<span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white">Live</span>
|
|
83
|
+
) : (
|
|
84
|
+
<span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">Not found yet</span>
|
|
85
|
+
)}
|
|
86
|
+
</dd>
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
</dl>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DomainDkimRotationProps {
|
|
94
|
+
domain: Domain;
|
|
95
|
+
/** The domain's DNS checklist, which says how each record is published and carries the rotation's records. */
|
|
96
|
+
checks: DnsRecordCheck[];
|
|
97
|
+
/** Called after a change was made, so the page can load the domain and its checklist again. */
|
|
98
|
+
onChanged: () => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A domain's DKIM key rotation (spec 6.2): the active selector and its age, the phase with its next step, "Rotate now", "Key compromised", "Cancel rotation"
|
|
103
|
+
* and the pause of scheduled rotation, and, for a record the administrator publishes by hand, the record to add. Nothing is shown on a server without the
|
|
104
|
+
* rotation route.
|
|
105
|
+
*/
|
|
106
|
+
export default function DomainDkimRotation({ domain, checks, onChanged }: DomainDkimRotationProps) {
|
|
107
|
+
// `undefined` while loading, `null` where the server has no rotation route (an older server).
|
|
108
|
+
const [view, setView] = useState<DkimRotationView | null | undefined>(undefined);
|
|
109
|
+
// Set when the first read failed for a reason other than a missing route, so the card stays and offers to try again.
|
|
110
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
111
|
+
const [busy, setBusy] = useState(false);
|
|
112
|
+
const [error, setError] = useState<string | null>(null);
|
|
113
|
+
const [confirming, setConfirming] = useState<"compromised" | "cancel" | null>(null);
|
|
114
|
+
const latest = useRef(0);
|
|
115
|
+
|
|
116
|
+
// Reads the rotation again; an answer to an older request is dropped. Only a 404 means the server has no rotation route; any other failure keeps what
|
|
117
|
+
// is shown, or, where nothing is shown yet, the card with a way to try again.
|
|
118
|
+
function load(): Promise<void> {
|
|
119
|
+
const id = ++latest.current;
|
|
120
|
+
return getDkimRotation(domain.uid).then(
|
|
121
|
+
(answer) => {
|
|
122
|
+
if (id === latest.current) {
|
|
123
|
+
setView(answer);
|
|
124
|
+
setLoadFailed(false);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
(err) => {
|
|
128
|
+
if (id === latest.current) {
|
|
129
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
130
|
+
setView((current) => current ?? null);
|
|
131
|
+
} else {
|
|
132
|
+
setLoadFailed(true);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The domain's version moves with every change the rotation job makes, so the panel follows it.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
void load();
|
|
142
|
+
}, [domain.uid, domain.version]);
|
|
143
|
+
|
|
144
|
+
function card(children: ReactNode) {
|
|
145
|
+
return (
|
|
146
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
147
|
+
<h2 className="text-sm font-bold uppercase tracking-wide mb-3">DKIM key rotation</h2>
|
|
148
|
+
{children}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (view === null || (view === undefined && !loadFailed)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (!view) {
|
|
157
|
+
return card(
|
|
158
|
+
<>
|
|
159
|
+
<Alert>Could not load the DKIM rotation.</Alert>
|
|
160
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => void load()}>
|
|
161
|
+
Reload the rotation
|
|
162
|
+
</Button>
|
|
163
|
+
</>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function run(action: () => Promise<unknown>, fallback: string) {
|
|
168
|
+
setBusy(true);
|
|
169
|
+
setError(null);
|
|
170
|
+
try {
|
|
171
|
+
await action();
|
|
172
|
+
setConfirming(null);
|
|
173
|
+
await load();
|
|
174
|
+
onChanged();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
setError(actionErrorMessage(err, fallback));
|
|
177
|
+
} finally {
|
|
178
|
+
setBusy(false);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const state: DkimRotationState = view.rotation ?? { phase: "idle" };
|
|
183
|
+
const phase = state.phase;
|
|
184
|
+
const blockerText = <p className="text-sm text-text-muted mb-3">{view.blocker ? BLOCKER_MESSAGES[view.blocker] : "This domain's DKIM key doesn't rotate."}</p>;
|
|
185
|
+
// A key that can't rotate offers nothing, unless a rotation is already in progress (the provider was swapped, the key re-imported): that one can still be
|
|
186
|
+
// seen and cancelled, though nothing new starts.
|
|
187
|
+
if (!view.rotatable && phase === "idle") {
|
|
188
|
+
return card(blockerText);
|
|
189
|
+
}
|
|
190
|
+
const rotatable = view.rotatable;
|
|
191
|
+
const next = state.next;
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
|
|
194
|
+
// The active key is the new one from the switch on; before, since the last rotation or the domain's creation.
|
|
195
|
+
const activeSince = (phase === "switching" || phase === "overlap") && state.switchedAt ? state.switchedAt : (state.lastRotatedAt ?? domain.dateCreated);
|
|
196
|
+
const reason: DkimRotationReason | undefined = phase === "pending" ? next?.reason : phase === "idle" ? undefined : state.reason;
|
|
197
|
+
// Once the key being replaced is reported compromised there is nothing more to report; an overlap is retired at once.
|
|
198
|
+
const canCompromise = rotatable && (phase === "idle" || phase === "overlap" || (phase === "pending" && next?.reason !== "compromised") || (phase === "switching" && state.reason !== "compromised"));
|
|
199
|
+
// An overlap finishes on its own.
|
|
200
|
+
const canCancel = phase === "pending" || phase === "switching";
|
|
201
|
+
|
|
202
|
+
const nextCheck = checks.find((check) => check.type === "dkim_next" && check.configured);
|
|
203
|
+
const previousCheck = checks.find((check) => check.type === "dkim_previous" && check.configured);
|
|
204
|
+
const retireAt = state.previous?.retireAt;
|
|
205
|
+
const retired = !!retireAt && new Date(retireAt).getTime() <= now;
|
|
206
|
+
const waitingDays = phase === "pending" && next && !next.liveSince ? daysSince(next.createdAt, now) : 0;
|
|
207
|
+
|
|
208
|
+
let step: ReactNode;
|
|
209
|
+
if (phase === "pending" && next) {
|
|
210
|
+
const selector = <code className="text-xs">{next.selector}</code>;
|
|
211
|
+
if (!next.publicKey) {
|
|
212
|
+
step = <>Creating the new key (selector {selector}).</>;
|
|
213
|
+
} else if (!next.liveSince) {
|
|
214
|
+
step = (
|
|
215
|
+
<>
|
|
216
|
+
Waiting for the new record for {selector} to be live.{" "}
|
|
217
|
+
{next.reason === "compromised" ? "Signing switches to it as soon as it is." : "Signing keeps using the current key until then."}
|
|
218
|
+
</>
|
|
219
|
+
);
|
|
220
|
+
} else if (next.reason === "compromised") {
|
|
221
|
+
step = <>The new record is live (since {formatDate(next.liveSince)}). Signing switches to the new key next.</>;
|
|
222
|
+
} else {
|
|
223
|
+
step = (
|
|
224
|
+
<>
|
|
225
|
+
The new record has been live since {formatDate(next.liveSince)}. Signing switches to the new key once it has been live long enough for
|
|
226
|
+
resolvers to drop any cached negative answer.
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
} else if (phase === "switching") {
|
|
231
|
+
step = <>Switching: signing has moved to the new key, and the mail server hasn't confirmed it yet.</>;
|
|
232
|
+
} else if (phase === "overlap") {
|
|
233
|
+
step = retireAt ? (
|
|
234
|
+
<>The old key retires on {formatDate(retireAt)}. Mail signed with it that is still in transit keeps verifying until then.</>
|
|
235
|
+
) : (
|
|
236
|
+
<>The old key retires shortly.</>
|
|
237
|
+
);
|
|
238
|
+
} else {
|
|
239
|
+
step = <>No rotation is in progress.</>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const scheduleNote =
|
|
243
|
+
phase !== "idle" ? null : state.paused ? (
|
|
244
|
+
<p className="text-sm text-text-muted mb-3">Scheduled rotation is paused.</p>
|
|
245
|
+
) : view.nextScheduledAt ? (
|
|
246
|
+
<p className="text-sm text-text-muted mb-3">Next scheduled rotation: {formatDate(view.nextScheduledAt)}.</p>
|
|
247
|
+
) : (
|
|
248
|
+
<p className="text-sm text-text-muted mb-3">No rotation is scheduled.</p>
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// A record published by hand is shown to copy; one a provider publishes is only mentioned.
|
|
252
|
+
let records: ReactNode = null;
|
|
253
|
+
if (phase === "pending" && nextCheck && next?.publicKey) {
|
|
254
|
+
if (publishedByHand(nextCheck)) {
|
|
255
|
+
records = (
|
|
256
|
+
<div className="mb-3">
|
|
257
|
+
<p className="text-sm font-semibold mb-2">{nextCheck.matches ? "The record you published is live." : "Publish this record to finish rotating"}</p>
|
|
258
|
+
<RecordToPublish check={nextCheck} noun="new DKIM key" showLive />
|
|
259
|
+
{waitingDays >= REMINDER_DAYS && !nextCheck.matches && (
|
|
260
|
+
<div role="note" className="text-sm rounded-sm py-3 px-3.5 bg-warning text-warning-contrast">
|
|
261
|
+
This record has been waiting for {waitingDays} days. Signing doesn't switch until it is live, so add it at your DNS host, or cancel the
|
|
262
|
+
rotation.
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
} else if (nextCheck.publication === "conflict") {
|
|
268
|
+
records = <p className="text-sm text-text-muted mb-3">The DNS provider can't publish this record while another one is in the way; see the checklist above.</p>;
|
|
269
|
+
} else {
|
|
270
|
+
records = <p className="text-sm text-text-muted mb-3">This record is published automatically by the DNS provider.</p>;
|
|
271
|
+
}
|
|
272
|
+
} else if ((phase === "switching" || phase === "overlap") && previousCheck && publishedByHand(previousCheck)) {
|
|
273
|
+
records = (
|
|
274
|
+
<div className="mb-3">
|
|
275
|
+
<p className="text-sm font-semibold mb-2">
|
|
276
|
+
{retired
|
|
277
|
+
? "The old key has retired: you may now remove this record."
|
|
278
|
+
: phase === "overlap" && retireAt
|
|
279
|
+
? `Keep this record published until ${formatDate(retireAt)}, then you may remove it.`
|
|
280
|
+
: "Keep this record published until the old key retires."}
|
|
281
|
+
</p>
|
|
282
|
+
<RecordToPublish check={previousCheck} noun="previous DKIM key" showLive={false} />
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const compromisedText =
|
|
288
|
+
phase === "overlap"
|
|
289
|
+
? "The old record and key are removed now, without waiting for the overlap to end. Mail signed with the old key that is still in transit may then fail DKIM."
|
|
290
|
+
: phase === "switching"
|
|
291
|
+
? "The old record is removed as soon as the mail server confirms the switch, with no overlap. Mail signed with the old key that is still in transit may then fail DKIM."
|
|
292
|
+
: "Signing moves to the new key as soon as its record is live (the publish wait is skipped), and the old record is removed as soon as the mail server confirms the switch, with no overlap. Mail signed with the old key that is still in transit may then fail DKIM.";
|
|
293
|
+
const cancelText =
|
|
294
|
+
phase === "switching"
|
|
295
|
+
? "Signing goes back to the previous key, and the new key is deleted. The cancel is refused if the previous key is gone or the mail server can't be pointed back at it. The next scheduled rotation counts from now."
|
|
296
|
+
: "The new key is deleted and its record is no longer published. Signing stays with the current key. The next scheduled rotation counts from now.";
|
|
297
|
+
|
|
298
|
+
// The refusal a dialog shows belongs to it, so it goes with it rather than moving to the card.
|
|
299
|
+
function closeDialog() {
|
|
300
|
+
if (!busy) {
|
|
301
|
+
setConfirming(null);
|
|
302
|
+
setError(null);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function openDialog(which: "compromised" | "cancel") {
|
|
307
|
+
setError(null);
|
|
308
|
+
setConfirming(which);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<>
|
|
313
|
+
{card(
|
|
314
|
+
<>
|
|
315
|
+
{!rotatable && blockerText}
|
|
316
|
+
{domain.dkimSelector && (
|
|
317
|
+
<p className="text-sm mb-3">
|
|
318
|
+
Active selector <code className="text-xs">{domain.dkimSelector}</code>, in use for {formatDays(daysSince(activeSince, now))}.
|
|
319
|
+
</p>
|
|
320
|
+
)}
|
|
321
|
+
<p className="text-sm mb-1">{step}</p>
|
|
322
|
+
{reason && <p className="text-sm text-text-muted mb-3">{REASON_MESSAGES[reason]}</p>}
|
|
323
|
+
{scheduleNote}
|
|
324
|
+
{state.lastError && <Alert>{state.lastError}</Alert>}
|
|
325
|
+
{records}
|
|
326
|
+
{error && confirming === null && <Alert>{error}</Alert>}
|
|
327
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
328
|
+
{rotatable && phase === "idle" && (
|
|
329
|
+
<Button type="button" className="!w-auto" disabled={busy} onClick={() => void run(() => rotateDkim(domain.uid), "Could not start the rotation.")}>
|
|
330
|
+
Rotate now
|
|
331
|
+
</Button>
|
|
332
|
+
)}
|
|
333
|
+
{canCompromise && (
|
|
334
|
+
<Button
|
|
335
|
+
type="button"
|
|
336
|
+
variant="secondary"
|
|
337
|
+
className="!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger"
|
|
338
|
+
disabled={busy}
|
|
339
|
+
onClick={() => openDialog("compromised")}
|
|
340
|
+
>
|
|
341
|
+
Key compromised: rotate now
|
|
342
|
+
</Button>
|
|
343
|
+
)}
|
|
344
|
+
{canCancel && (
|
|
345
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={() => openDialog("cancel")}>
|
|
346
|
+
Cancel rotation
|
|
347
|
+
</Button>
|
|
348
|
+
)}
|
|
349
|
+
{rotatable && (
|
|
350
|
+
<Button
|
|
351
|
+
type="button"
|
|
352
|
+
variant="secondary"
|
|
353
|
+
className="!w-auto"
|
|
354
|
+
disabled={busy}
|
|
355
|
+
onClick={() =>
|
|
356
|
+
void run(
|
|
357
|
+
() => setDkimRotationPaused(domain.uid, !state.paused),
|
|
358
|
+
state.paused ? "Could not resume scheduled rotation." : "Could not pause scheduled rotation."
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
>
|
|
362
|
+
{state.paused ? "Resume scheduled rotation" : "Pause scheduled rotation"}
|
|
363
|
+
</Button>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
<Modal open={confirming === "compromised"} onClose={closeDialog} title="Key compromised">
|
|
370
|
+
<p className="text-sm mb-3">{compromisedText}</p>
|
|
371
|
+
{error && <Alert>{error}</Alert>}
|
|
372
|
+
<div className="flex gap-3 justify-end mt-5">
|
|
373
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={closeDialog}>
|
|
374
|
+
Back
|
|
375
|
+
</Button>
|
|
376
|
+
<Button
|
|
377
|
+
type="button"
|
|
378
|
+
className="!w-auto"
|
|
379
|
+
loading={busy}
|
|
380
|
+
disabled={busy}
|
|
381
|
+
onClick={() => void run(() => rotateDkim(domain.uid, { compromised: true }), "Could not rotate the key.")}
|
|
382
|
+
>
|
|
383
|
+
Confirm
|
|
384
|
+
</Button>
|
|
385
|
+
</div>
|
|
386
|
+
</Modal>
|
|
387
|
+
|
|
388
|
+
<Modal open={confirming === "cancel"} onClose={closeDialog} title="Cancel rotation">
|
|
389
|
+
<p className="text-sm mb-3">{cancelText}</p>
|
|
390
|
+
{error && <Alert>{error}</Alert>}
|
|
391
|
+
<div className="flex gap-3 justify-end mt-5">
|
|
392
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={closeDialog}>
|
|
393
|
+
Back
|
|
394
|
+
</Button>
|
|
395
|
+
<Button
|
|
396
|
+
type="button"
|
|
397
|
+
className="!w-auto"
|
|
398
|
+
loading={busy}
|
|
399
|
+
disabled={busy}
|
|
400
|
+
onClick={() => void run(() => cancelDkimRotation(domain.uid), "Could not cancel the rotation.")}
|
|
401
|
+
>
|
|
402
|
+
Confirm
|
|
403
|
+
</Button>
|
|
404
|
+
</div>
|
|
405
|
+
</Modal>
|
|
406
|
+
</>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { DnsProvider, listDnsProviders } from "../../../../../lib/admin/dnsProvidersApi.js";
|
|
7
|
+
import { assignDnsProvider, Domain } from "../../../../../lib/admin/domainsApi.js";
|
|
8
|
+
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
9
|
+
import FormField from "../../../../../lib/components/forms/FormField.js";
|
|
10
|
+
import Alert from "../ActionAlert.js";
|
|
11
|
+
import { actionErrorMessage } from "../elevation.js";
|
|
12
|
+
|
|
13
|
+
const INPUT_CLASS =
|
|
14
|
+
"w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary disabled:opacity-55";
|
|
15
|
+
|
|
16
|
+
/** The length of the longest zone of `provider` that covers `name` (the name itself or a name under it), 0 when none does. */
|
|
17
|
+
function coveringZoneLength(name: string, provider: DnsProvider): number {
|
|
18
|
+
return Math.max(0, ...provider.zones.filter((zone) => name === zone || name.endsWith(`.${zone}`)).map((zone) => zone.length));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The providers the select offers: only the one whose zone covers the domain most specifically, since the server accepts no other (another config's
|
|
22
|
+
* zone nested under the parent's is left out of the parent's external-dns instance), and the one assigned now, whatever it covers. */
|
|
23
|
+
function offeredProviders(domain: Domain, providers: DnsProvider[]): DnsProvider[] {
|
|
24
|
+
const lengths = providers.map((provider) => coveringZoneLength(domain.name, provider));
|
|
25
|
+
const longest = Math.max(0, ...lengths);
|
|
26
|
+
return providers.filter((provider, i) => (longest > 0 && lengths[i] === longest) || provider.uid === domain.dnsProviderUid);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DomainDnsProviderProps {
|
|
30
|
+
domain: Domain;
|
|
31
|
+
/** Called after the assignment was saved, so the page can load the domain and its checklist again. */
|
|
32
|
+
onChanged: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The domain's DNS provider: "Manual", where the administrator adds the records by hand, or the provider config whose external-dns instance publishes
|
|
37
|
+
* them. Nothing is shown on a server without DNS providers.
|
|
38
|
+
*/
|
|
39
|
+
export default function DomainDnsProvider({ domain, onChanged }: DomainDnsProviderProps) {
|
|
40
|
+
// `undefined` while loading, `null` where the server has no DNS providers (an older server): then there is nothing to choose.
|
|
41
|
+
const [providers, setProviders] = useState<DnsProvider[] | null | undefined>(undefined);
|
|
42
|
+
const [selected, setSelected] = useState(domain.dnsProviderUid ?? "");
|
|
43
|
+
const [saving, setSaving] = useState(false);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
const [saved, setSaved] = useState(false);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
listDnsProviders()
|
|
49
|
+
.then(setProviders)
|
|
50
|
+
.catch(() => setProviders(null));
|
|
51
|
+
}, [domain.uid]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setSelected(domain.dnsProviderUid ?? "");
|
|
55
|
+
}, [domain.dnsProviderUid]);
|
|
56
|
+
|
|
57
|
+
if (!providers) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function handleSave() {
|
|
62
|
+
setSaving(true);
|
|
63
|
+
setError(null);
|
|
64
|
+
setSaved(false);
|
|
65
|
+
try {
|
|
66
|
+
await assignDnsProvider(domain.uid, selected || null);
|
|
67
|
+
setSaved(true);
|
|
68
|
+
onChanged();
|
|
69
|
+
} catch (err) {
|
|
70
|
+
setError(actionErrorMessage(err, "Could not change the DNS provider."));
|
|
71
|
+
} finally {
|
|
72
|
+
setSaving(false);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const assigned = domain.dnsProviderUid ?? "";
|
|
77
|
+
const offered = offeredProviders(domain, providers);
|
|
78
|
+
const locked = !!domain.apexTxt?.managed;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
82
|
+
<h2 className="text-sm font-bold uppercase tracking-wide mb-3">DNS provider</h2>
|
|
83
|
+
{offered.length === 0 && !assigned ? (
|
|
84
|
+
<p className="text-sm text-text-muted">
|
|
85
|
+
No DNS provider covers {domain.name}. To have its DNS records published automatically, add one under{" "}
|
|
86
|
+
<a href="/admin/integrations/dns-providers" className="text-primary-dark hover:underline">
|
|
87
|
+
DNS providers
|
|
88
|
+
</a>{" "}
|
|
89
|
+
with a zone that includes it. Until then, add the records below at your DNS host.
|
|
90
|
+
</p>
|
|
91
|
+
) : (
|
|
92
|
+
<>
|
|
93
|
+
<p className="text-sm text-text-muted mb-3">
|
|
94
|
+
Choose the DNS provider that publishes this domain's records, or add them yourself at your DNS host.
|
|
95
|
+
</p>
|
|
96
|
+
{error && <Alert>{error}</Alert>}
|
|
97
|
+
<FormField label="DNS provider" htmlFor="dnsProviderUid">
|
|
98
|
+
<select
|
|
99
|
+
id="dnsProviderUid"
|
|
100
|
+
className={INPUT_CLASS}
|
|
101
|
+
value={selected}
|
|
102
|
+
disabled={locked}
|
|
103
|
+
onChange={(e) => {
|
|
104
|
+
setSelected(e.target.value);
|
|
105
|
+
setSaved(false);
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<option value="">Manual: I publish the records myself</option>
|
|
109
|
+
{offered.map((provider) => (
|
|
110
|
+
<option key={provider.uid} value={provider.uid}>
|
|
111
|
+
{provider.name + (provider.enabled ? "" : " (disabled)")}
|
|
112
|
+
</option>
|
|
113
|
+
))}
|
|
114
|
+
{assigned && !providers.some((provider) => provider.uid === assigned) && (
|
|
115
|
+
<option value={assigned}>{assigned} (no longer exists)</option>
|
|
116
|
+
)}
|
|
117
|
+
</select>
|
|
118
|
+
</FormField>
|
|
119
|
+
{locked && (
|
|
120
|
+
<p className="text-sm text-text-muted mb-3">
|
|
121
|
+
RapidMX manages this domain's root TXT records through its provider. Stop managing the root TXT records first, then change the provider.
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
{assigned && selected !== assigned && (
|
|
125
|
+
<p className="text-sm text-text-muted mb-3">
|
|
126
|
+
Saving removes the records the current provider published for this domain from your DNS, until the new provider has published them
|
|
127
|
+
(or you have added them yourself).
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
<div className="flex items-center gap-3">
|
|
131
|
+
<Button
|
|
132
|
+
type="button"
|
|
133
|
+
className="!w-auto"
|
|
134
|
+
aria-label="Save DNS provider"
|
|
135
|
+
loading={saving}
|
|
136
|
+
disabled={saving || locked || selected === assigned}
|
|
137
|
+
onClick={handleSave}
|
|
138
|
+
>
|
|
139
|
+
Save
|
|
140
|
+
</Button>
|
|
141
|
+
{saved && <span className="text-sm text-text-muted">Saved.</span>}
|
|
142
|
+
</div>
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|