@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.
Files changed (123) hide show
  1. package/apps/admin/configuration-encryption/index.tsx +17 -0
  2. package/apps/admin/domains/[uid].tsx +17 -4
  3. package/apps/admin/integrations/dns-providers/[uid].tsx +205 -0
  4. package/apps/admin/integrations/dns-providers/index.tsx +164 -0
  5. package/apps/admin/integrations/dns-providers/new/index.tsx +55 -0
  6. package/apps/admin/integrations/dns-resolver/index.tsx +16 -0
  7. package/apps/admin/integrations/giphy/index.tsx +16 -0
  8. package/apps/admin/integrations/index.tsx +31 -0
  9. package/apps/admin/integrations/plugin-registry/index.tsx +16 -0
  10. package/apps/admin/integrations/signing-ca/index.tsx +16 -0
  11. package/apps/admin/integrations/stun/index.tsx +16 -0
  12. package/apps/shared/components/admin/configEncryption/ConfigKeysManager.tsx +529 -0
  13. package/apps/shared/components/admin/configEncryption/keyringParts.ts +37 -0
  14. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +20 -4
  15. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +23 -6
  16. package/apps/shared/components/admin/diagnostics/KeyringCard.tsx +144 -0
  17. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +21 -6
  18. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +15 -3
  19. package/apps/shared/components/admin/integrations/DnsProviderForm.tsx +354 -0
  20. package/apps/shared/components/admin/integrations/DnsProviderHosts.tsx +197 -0
  21. package/apps/shared/components/admin/integrations/DnsProviderOperations.tsx +115 -0
  22. package/apps/shared/components/admin/integrations/DnsResolverForm.tsx +61 -0
  23. package/apps/shared/components/admin/integrations/GiphyForm.tsx +59 -0
  24. package/apps/shared/components/admin/integrations/IntegrationSectionForm.tsx +234 -0
  25. package/apps/shared/components/admin/integrations/IntegrationSectionPage.tsx +40 -0
  26. package/apps/shared/components/admin/integrations/IntegrationsLayout.tsx +72 -0
  27. package/apps/shared/components/admin/integrations/PluginRegistryForm.tsx +230 -0
  28. package/apps/shared/components/admin/integrations/SecretField.tsx +91 -0
  29. package/apps/shared/components/admin/integrations/SigningCaForm.tsx +95 -0
  30. package/apps/shared/components/admin/integrations/StunForm.tsx +63 -0
  31. package/apps/shared/components/admin/integrations/dnsProviderParts.tsx +72 -0
  32. package/apps/shared/components/admin/layout/AdminShell.tsx +12 -1
  33. package/apps/shared/components/admin/settings/CopyableValue.tsx +16 -0
  34. package/apps/shared/components/admin/settings/DomainApexTxt.tsx +319 -0
  35. package/apps/shared/components/admin/settings/DomainDkimRotation.tsx +408 -0
  36. package/apps/shared/components/admin/settings/DomainDnsProvider.tsx +147 -0
  37. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +174 -17
  38. package/apps/shared/components/admin/setup/SetupWizard.tsx +39 -6
  39. package/dist/apps/admin/configuration-encryption/index.d.ts +4 -0
  40. package/dist/apps/admin/configuration-encryption/index.js +7 -0
  41. package/dist/apps/admin/domains/[uid].js +11 -4
  42. package/dist/apps/admin/integrations/dns-providers/[uid].d.ts +7 -0
  43. package/dist/apps/admin/integrations/dns-providers/[uid].js +117 -0
  44. package/dist/apps/admin/integrations/dns-providers/index.d.ts +3 -0
  45. package/dist/apps/admin/integrations/dns-providers/index.js +68 -0
  46. package/dist/apps/admin/integrations/dns-providers/new/index.d.ts +3 -0
  47. package/dist/apps/admin/integrations/dns-providers/new/index.js +31 -0
  48. package/dist/apps/admin/integrations/dns-resolver/index.d.ts +3 -0
  49. package/dist/apps/admin/integrations/dns-resolver/index.js +6 -0
  50. package/dist/apps/admin/integrations/giphy/index.d.ts +3 -0
  51. package/dist/apps/admin/integrations/giphy/index.js +6 -0
  52. package/dist/apps/admin/integrations/index.d.ts +4 -0
  53. package/dist/apps/admin/integrations/index.js +7 -0
  54. package/dist/apps/admin/integrations/plugin-registry/index.d.ts +3 -0
  55. package/dist/apps/admin/integrations/plugin-registry/index.js +6 -0
  56. package/dist/apps/admin/integrations/signing-ca/index.d.ts +3 -0
  57. package/dist/apps/admin/integrations/signing-ca/index.js +6 -0
  58. package/dist/apps/admin/integrations/stun/index.d.ts +3 -0
  59. package/dist/apps/admin/integrations/stun/index.js +6 -0
  60. package/dist/apps/shared/components/admin/configEncryption/ConfigKeysManager.d.ts +9 -0
  61. package/dist/apps/shared/components/admin/configEncryption/ConfigKeysManager.js +187 -0
  62. package/dist/apps/shared/components/admin/configEncryption/keyringParts.d.ts +10 -0
  63. package/dist/apps/shared/components/admin/configEncryption/keyringParts.js +26 -0
  64. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +20 -4
  65. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +8 -4
  66. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +7 -5
  67. package/dist/apps/shared/components/admin/diagnostics/KeyringCard.d.ts +18 -0
  68. package/dist/apps/shared/components/admin/diagnostics/KeyringCard.js +51 -0
  69. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +9 -1
  70. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +10 -3
  71. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +13 -3
  72. package/dist/apps/shared/components/admin/integrations/DnsProviderForm.d.ts +17 -0
  73. package/dist/apps/shared/components/admin/integrations/DnsProviderForm.js +147 -0
  74. package/dist/apps/shared/components/admin/integrations/DnsProviderHosts.d.ts +10 -0
  75. package/dist/apps/shared/components/admin/integrations/DnsProviderHosts.js +101 -0
  76. package/dist/apps/shared/components/admin/integrations/DnsProviderOperations.d.ts +8 -0
  77. package/dist/apps/shared/components/admin/integrations/DnsProviderOperations.js +60 -0
  78. package/dist/apps/shared/components/admin/integrations/DnsResolverForm.d.ts +7 -0
  79. package/dist/apps/shared/components/admin/integrations/DnsResolverForm.js +15 -0
  80. package/dist/apps/shared/components/admin/integrations/GiphyForm.d.ts +7 -0
  81. package/dist/apps/shared/components/admin/integrations/GiphyForm.js +11 -0
  82. package/dist/apps/shared/components/admin/integrations/IntegrationSectionForm.d.ts +54 -0
  83. package/dist/apps/shared/components/admin/integrations/IntegrationSectionForm.js +131 -0
  84. package/dist/apps/shared/components/admin/integrations/IntegrationSectionPage.d.ts +12 -0
  85. package/dist/apps/shared/components/admin/integrations/IntegrationSectionPage.js +21 -0
  86. package/dist/apps/shared/components/admin/integrations/IntegrationsLayout.d.ts +14 -0
  87. package/dist/apps/shared/components/admin/integrations/IntegrationsLayout.js +46 -0
  88. package/dist/apps/shared/components/admin/integrations/PluginRegistryForm.d.ts +7 -0
  89. package/dist/apps/shared/components/admin/integrations/PluginRegistryForm.js +90 -0
  90. package/dist/apps/shared/components/admin/integrations/SecretField.d.ts +25 -0
  91. package/dist/apps/shared/components/admin/integrations/SecretField.js +25 -0
  92. package/dist/apps/shared/components/admin/integrations/SigningCaForm.d.ts +7 -0
  93. package/dist/apps/shared/components/admin/integrations/SigningCaForm.js +25 -0
  94. package/dist/apps/shared/components/admin/integrations/StunForm.d.ts +9 -0
  95. package/dist/apps/shared/components/admin/integrations/StunForm.js +26 -0
  96. package/dist/apps/shared/components/admin/integrations/dnsProviderParts.d.ts +28 -0
  97. package/dist/apps/shared/components/admin/integrations/dnsProviderParts.js +43 -0
  98. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  99. package/dist/apps/shared/components/admin/layout/AdminShell.js +8 -1
  100. package/dist/apps/shared/components/admin/settings/CopyableValue.d.ts +6 -0
  101. package/dist/apps/shared/components/admin/settings/CopyableValue.js +6 -0
  102. package/dist/apps/shared/components/admin/settings/DomainApexTxt.d.ts +13 -0
  103. package/dist/apps/shared/components/admin/settings/DomainApexTxt.js +148 -0
  104. package/dist/apps/shared/components/admin/settings/DomainDkimRotation.d.ts +15 -0
  105. package/dist/apps/shared/components/admin/settings/DomainDkimRotation.js +200 -0
  106. package/dist/apps/shared/components/admin/settings/DomainDnsProvider.d.ts +12 -0
  107. package/dist/apps/shared/components/admin/settings/DomainDnsProvider.js +70 -0
  108. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +3 -3
  109. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +61 -8
  110. package/dist/apps/shared/components/admin/setup/SetupWizard.js +9 -1
  111. package/dist/lib/admin/configKeysApi.d.ts +96 -0
  112. package/dist/lib/admin/configKeysApi.js +34 -0
  113. package/dist/lib/admin/dnsProvidersApi.d.ts +172 -0
  114. package/dist/lib/admin/dnsProvidersApi.js +70 -0
  115. package/dist/lib/admin/domainsApi.d.ts +116 -1
  116. package/dist/lib/admin/domainsApi.js +33 -0
  117. package/dist/lib/admin/integrationsApi.d.ts +75 -0
  118. package/dist/lib/admin/integrationsApi.js +31 -0
  119. package/lib/admin/configKeysApi.ts +117 -0
  120. package/lib/admin/dnsProvidersApi.ts +241 -0
  121. package/lib/admin/domainsApi.ts +145 -1
  122. package/lib/admin/integrationsApi.ts +107 -0
  123. package/package.json +38 -37
@@ -0,0 +1,529 @@
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
+ activateConfigKey,
8
+ ConfigKeys,
9
+ configKeysOfRefusal,
10
+ getConfigKeys,
11
+ rotateConfigKey,
12
+ } from "../../../../../lib/admin/configKeysApi.js";
13
+ import Button from "../../../../../lib/components/buttons/Button.js";
14
+ import Modal from "../../../../../lib/components/overlays/Modal.js";
15
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
16
+ import Alert from "../ActionAlert.js";
17
+ import { actionErrorMessage } from "../elevation.js";
18
+ import { Badge, formatTime } from "../integrations/dnsProviderParts.js";
19
+ import { activeKidOf, ageText, isRunning } from "./keyringParts.js";
20
+
21
+ /** 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. */
22
+ export const POLL_MS = 10_000;
23
+
24
+ /** What an administrator asked for that the dialogs confirm. */
25
+ type Confirming = "compromised" | "activate";
26
+
27
+ function copiesText(count: number): string {
28
+ return count === 1 ? "1 copy" : `${count} copies`;
29
+ }
30
+
31
+ /** The server copies a rotation is waiting for, one per line. */
32
+ function CopyList({ label, copies }: { label: string; copies: string[] }) {
33
+ return (
34
+ <ul aria-label={label} className="list-disc pl-5 text-sm mb-3">
35
+ {copies.map((copy) => (
36
+ <li key={copy}>
37
+ <code className="text-xs">{copy}</code>
38
+ </li>
39
+ ))}
40
+ </ul>
41
+ );
42
+ }
43
+
44
+ function Note({ children }: { children: ReactNode }) {
45
+ return (
46
+ <div role="note" className="text-sm rounded-sm py-3 px-3.5 mb-3 bg-warning text-warning-contrast">
47
+ {children}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ function Card({ title, children }: { title: string; children: ReactNode }) {
53
+ return (
54
+ <div className="bg-surface border border-border rounded-md p-6 mb-5">
55
+ <h2 className="text-sm font-bold uppercase tracking-wide mb-3">{title}</h2>
56
+ {children}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /**
62
+ * 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
63
+ * "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
64
+ * secret settings' encryption, and the reminders to back up the keyring and, after a compromise, to replace the credentials at their providers.
65
+ */
66
+ export default function ConfigKeysManager() {
67
+ const [keys, setKeys] = useState<ConfigKeys | null>(null);
68
+ // `true` where the server has no key route (an older server).
69
+ const [missing, setMissing] = useState(false);
70
+ const [loadError, setLoadError] = useState<string | null>(null);
71
+ // The failure of a read after the first, which leaves what is shown as it was.
72
+ const [refreshError, setRefreshError] = useState<string | null>(null);
73
+ // The copies that were waiting when "Activate anyway" was opened: the dialog names what the administrator was shown, not what a later read says.
74
+ const [staleCopies, setStaleCopies] = useState<string[]>([]);
75
+ const [busy, setBusy] = useState(false);
76
+ const [error, setError] = useState<string | null>(null);
77
+ // What the server's refusal of a stale request taught: the progress shown is the rotation's, not the one the administrator expected.
78
+ const [notice, setNotice] = useState<string | null>(null);
79
+ const [confirming, setConfirming] = useState<Confirming | null>(null);
80
+ // Counts every finished read, so a failed poll is tried again too.
81
+ const [reads, setReads] = useState(0);
82
+ const latest = useRef(0);
83
+ // Whether a read has been answered: a missing route then means an older pod answered, not that the server has none.
84
+ const loaded = useRef(false);
85
+
86
+ // 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.
87
+ function load(): Promise<void> {
88
+ const id = ++latest.current;
89
+ return getConfigKeys()
90
+ .then(
91
+ (answer) => {
92
+ if (id === latest.current) {
93
+ loaded.current = true;
94
+ setKeys(answer);
95
+ setLoadError(null);
96
+ setRefreshError(null);
97
+ }
98
+ },
99
+ (err) => {
100
+ if (id !== latest.current) {
101
+ return;
102
+ }
103
+ if (loaded.current) {
104
+ setRefreshError(actionErrorMessage(err, "The server did not answer."));
105
+ } else if (err instanceof ApiRequestError && err.status === 404) {
106
+ setMissing(true);
107
+ } else {
108
+ setLoadError(actionErrorMessage(err, "Could not load the configuration keys."));
109
+ }
110
+ }
111
+ )
112
+ .then(() => setReads((count) => count + 1));
113
+ }
114
+
115
+ useEffect(() => {
116
+ void load();
117
+ // A read still on its way when the page is left must not set state.
118
+ return () => void latest.current++;
119
+ }, []);
120
+
121
+ const rotation = keys?.rotation;
122
+ const running = isRunning(rotation);
123
+ // While a rotation runs the copies and the server's job move it along, so the page follows.
124
+ useEffect(() => {
125
+ if (!running) {
126
+ return;
127
+ }
128
+ const timer = setTimeout(() => void load(), POLL_MS);
129
+ return () => clearTimeout(timer);
130
+ }, [running, reads]);
131
+
132
+ if (missing) {
133
+ return (
134
+ <p className="text-sm text-text-muted">
135
+ This server doesn&rsquo;t offer configuration encryption yet. Upgrade it to manage its keys here.
136
+ </p>
137
+ );
138
+ }
139
+ if (!keys) {
140
+ return loadError ? (
141
+ <>
142
+ <Alert>{loadError}</Alert>
143
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => void load()}>
144
+ Try again
145
+ </Button>
146
+ </>
147
+ ) : (
148
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
149
+ );
150
+ }
151
+
152
+ async function run(action: () => Promise<ConfigKeys>, fallback: string, refused: string) {
153
+ setBusy(true);
154
+ setError(null);
155
+ setNotice(null);
156
+ try {
157
+ const answer = await action();
158
+ // Drops a read that was on its way before this change.
159
+ latest.current++;
160
+ loaded.current = true;
161
+ setKeys(answer);
162
+ setRefreshError(null);
163
+ setConfirming(null);
164
+ } catch (err) {
165
+ // A 409 carries the rotation as it is: the request was for a state it is no longer in.
166
+ const current = configKeysOfRefusal(err);
167
+ if (current) {
168
+ latest.current++;
169
+ loaded.current = true;
170
+ setKeys(current);
171
+ setRefreshError(null);
172
+ setConfirming(null);
173
+ setNotice(refused);
174
+ } else {
175
+ setError(actionErrorMessage(err, fallback));
176
+ }
177
+ } finally {
178
+ setBusy(false);
179
+ }
180
+ }
181
+
182
+ // The refusal a dialog shows belongs to it, so it goes with it rather than moving to the card.
183
+ function closeDialog() {
184
+ if (!busy) {
185
+ setConfirming(null);
186
+ setError(null);
187
+ }
188
+ }
189
+
190
+ function openDialog(which: Confirming) {
191
+ setError(null);
192
+ setStaleCopies(waiting);
193
+ setConfirming(which);
194
+ }
195
+
196
+ const now = Date.now();
197
+ const newKid = rotation?.kid;
198
+ const waiting = rotation?.waitingFor ?? [];
199
+ const counted = keys.keys.some((key) => key.envelopes !== undefined);
200
+ const earlier = keys.keys.filter((key) => key.kid !== newKid).reduce((sum, key) => sum + (key.envelopes ?? 0), 0);
201
+ const onNew = keys.keys.filter((key) => key.kid === newKid).reduce((sum, key) => sum + (key.envelopes ?? 0), 0);
202
+ const unreadable = keys.reencryption?.unreadable ?? [];
203
+ const activeKid = activeKidOf(keys);
204
+ // A compromised rotation that failed: starting again from the plain button would drop the compromise.
205
+ const failedCompromised = rotation?.phase === "failed" && rotation.compromised;
206
+
207
+ let step: ReactNode;
208
+ switch (rotation?.phase) {
209
+ case "adding":
210
+ step = <p className="text-sm mb-3">Adding the new key to the keyring.</p>;
211
+ break;
212
+ case "activating":
213
+ step =
214
+ waiting.length > 0 ? (
215
+ <>
216
+ <p className="text-sm mb-2">{`Waiting for ${copiesText(waiting.length)} to load the new key`}</p>
217
+ <CopyList label="Copies that haven't loaded the new key" copies={waiting} />
218
+ <p className="text-sm text-text-muted mb-3">
219
+ Nothing breaks while it waits: the current key keeps encrypting, and the new one is used as
220
+ soon as every copy has it.
221
+ </p>
222
+ </>
223
+ ) : (
224
+ <p className="text-sm mb-3">Activating the new key.</p>
225
+ );
226
+ break;
227
+ case "reencrypting":
228
+ step = (
229
+ <>
230
+ <p className="text-sm mb-2">Re-encrypting saved values with the new key.</p>
231
+ {counted && (
232
+ <p className="text-sm text-text-muted mb-3">
233
+ {`${earlier === 1 ? "1 value is" : `${earlier} values are`} still on an earlier key; ${onNew} ${onNew === 1 ? "is" : "are"} on the new one.`}
234
+ </p>
235
+ )}
236
+ {waiting.length > 0 && (
237
+ <>
238
+ <p className="text-sm mb-2">{`Waiting for ${copiesText(waiting.length)} to start using the new key`}</p>
239
+ <CopyList label="Copies that don't yet use the new key" copies={waiting} />
240
+ {rotation?.activationForced && (
241
+ <p className="text-sm text-text-muted mb-3">
242
+ The key was activated before every copy had loaded it, so values are re-encrypted
243
+ only once these copies have loaded it or are replaced.
244
+ </p>
245
+ )}
246
+ </>
247
+ )}
248
+ </>
249
+ );
250
+ break;
251
+ case "retiring":
252
+ step = <p className="text-sm mb-3">Removing the old key from the keyring.</p>;
253
+ break;
254
+ case "failed":
255
+ step = <p className="text-sm mb-3">The last rotation failed.</p>;
256
+ break;
257
+ case "done":
258
+ step = (
259
+ <p className="text-sm mb-3">
260
+ {rotation.finishedAt
261
+ ? `The last rotation finished ${formatTime(rotation.finishedAt)}.`
262
+ : "The last rotation has finished."}
263
+ </p>
264
+ );
265
+ break;
266
+ default:
267
+ step = <p className="text-sm mb-3">No rotation is in progress.</p>;
268
+ }
269
+
270
+ return (
271
+ <>
272
+ <p className="text-sm text-text-muted mb-5 max-w-2xl">
273
+ The credentials, API keys and tokens saved in this console, and the secret settings of plugins, are
274
+ encrypted with these keys, so a copy of the database alone shows none of them. A key is only ever
275
+ replaced by an administrator, here.
276
+ </p>
277
+
278
+ <Card title="Keys">
279
+ {refreshError && (
280
+ <Alert>
281
+ Couldn&rsquo;t refresh the progress; what is shown may be out of date, and it is tried again
282
+ every few seconds. {refreshError}
283
+ </Alert>
284
+ )}
285
+ <div className="overflow-x-auto">
286
+ <table className="w-full text-sm border-collapse mb-3">
287
+ <thead>
288
+ <tr>
289
+ {["Key", "Created", "Age", "Values"].map((h) => (
290
+ <th
291
+ key={h}
292
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
293
+ >
294
+ {h}
295
+ </th>
296
+ ))}
297
+ </tr>
298
+ </thead>
299
+ <tbody>
300
+ {keys.keys.map((key) => (
301
+ <tr key={key.kid}>
302
+ <td className="py-2.5 px-2.5 border-b border-border">
303
+ <code className="text-xs">{key.kid}</code>
304
+ {key.kid === activeKid && <Badge>Active</Badge>}
305
+ </td>
306
+ <td className="py-2.5 px-2.5 border-b border-border">
307
+ {new Date(key.createdAt).toLocaleDateString()}
308
+ </td>
309
+ <td className="py-2.5 px-2.5 border-b border-border">
310
+ {ageText(key.createdAt, now)}
311
+ </td>
312
+ <td className="py-2.5 px-2.5 border-b border-border">
313
+ {key.envelopes ?? "Not counted yet"}
314
+ </td>
315
+ </tr>
316
+ ))}
317
+ </tbody>
318
+ </table>
319
+ </div>
320
+ {keys.countedAt && (
321
+ <p className="text-xs text-text-muted">Values were last counted {formatTime(keys.countedAt)}.</p>
322
+ )}
323
+ </Card>
324
+
325
+ <Card title="Rotation">
326
+ {!keys.available && (
327
+ <Alert>
328
+ Keys can&rsquo;t be rotated on this server: its keyring isn&rsquo;t kept where the server can
329
+ change it, or its copies can&rsquo;t be coordinated. See the deployment&rsquo;s configuration
330
+ encryption settings.
331
+ </Alert>
332
+ )}
333
+ {step}
334
+ {running && (
335
+ <p className="text-sm text-text-muted mb-3">
336
+ {`Started ${formatTime(rotation.requestedAt)}${rotation.compromised ? ", reported as a key compromise" : ""}.`}
337
+ </p>
338
+ )}
339
+ {rotation?.warning && <Note>{rotation.warning}</Note>}
340
+ {rotation?.lastError && <Alert>{rotation.lastError}</Alert>}
341
+ {rotation?.compromised && (
342
+ <Note>
343
+ Because the keyring may have been exposed, replace the credentials saved here at their providers
344
+ (DNS provider tokens, the Giphy key, registry tokens, plugin secrets), then enter the new ones.
345
+ A keyring exposed together with a copy of the database has exposed the saved values themselves;
346
+ rotating the key doesn&rsquo;t undo that.
347
+ </Note>
348
+ )}
349
+ {failedCompromised && (
350
+ <Note>
351
+ The failed rotation was reported as a key compromise. Starting again with &ldquo;Rotate
352
+ configuration key&rdquo; doesn&rsquo;t carry that over: use &ldquo;Key compromised: rotate
353
+ now&rdquo; to keep it.
354
+ </Note>
355
+ )}
356
+ {rotation?.phase === "done" && (
357
+ <Note>
358
+ Back up the keyring again now. A copy taken before this rotation doesn&rsquo;t have the new key,
359
+ so it can&rsquo;t read the values saved after it.
360
+ </Note>
361
+ )}
362
+ {notice && (
363
+ <p role="status" className="text-sm text-text-muted mb-3">
364
+ {notice}
365
+ </p>
366
+ )}
367
+ {error && confirming === null && <Alert>{error}</Alert>}
368
+ <div className="flex flex-wrap items-center gap-3">
369
+ {!running && (
370
+ <Button
371
+ type="button"
372
+ className="!w-auto"
373
+ disabled={busy || !keys.available}
374
+ onClick={() =>
375
+ void run(
376
+ () => rotateConfigKey(),
377
+ "Could not start the rotation.",
378
+ "A rotation was already in progress; its progress is shown."
379
+ )
380
+ }
381
+ >
382
+ Rotate configuration key
383
+ </Button>
384
+ )}
385
+ {(!running || !rotation.compromised) && (
386
+ <Button
387
+ type="button"
388
+ variant="secondary"
389
+ className="!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger"
390
+ disabled={busy || !keys.available}
391
+ onClick={() => openDialog("compromised")}
392
+ >
393
+ Key compromised: rotate now
394
+ </Button>
395
+ )}
396
+ {rotation?.phase === "activating" && newKid && (
397
+ <Button
398
+ type="button"
399
+ variant="secondary"
400
+ className="!w-auto"
401
+ disabled={busy}
402
+ onClick={() => openDialog("activate")}
403
+ >
404
+ Activate anyway
405
+ </Button>
406
+ )}
407
+ </div>
408
+ <p className="text-xs text-text-muted mt-4">
409
+ A database backup can&rsquo;t be read without the keyring: back up the keyring with it (the Secret
410
+ ending in <code>-config-keyring</code>, or its OpenBao entry), and keep each keyring backup as long
411
+ as the database backups it serves.
412
+ </p>
413
+ </Card>
414
+
415
+ {unreadable.length > 0 && (
416
+ <Card title="Values that can't be read">
417
+ <p className="text-sm mb-2">
418
+ These saved values couldn&rsquo;t be decrypted with any key in the keyring (a database restored
419
+ with an older keyring, for example). Enter each again where it is set.
420
+ </p>
421
+ <p className="text-sm text-text-muted mb-2">
422
+ {keys.reencryption?.finishedAt
423
+ ? `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.`
424
+ : "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."}
425
+ </p>
426
+ <ul className="list-disc pl-5 text-sm">
427
+ {unreadable.map((item) => (
428
+ <li
429
+ key={`${item.record}/${item.uid}/${item.field}`}
430
+ >{`${item.record} ${item.uid}, ${item.field} (key ${item.kid})`}</li>
431
+ ))}
432
+ </ul>
433
+ </Card>
434
+ )}
435
+
436
+ <Card title="Plugin secret settings">
437
+ {keys.pluginSecrets?.encryptedAt ? (
438
+ <p className="text-sm">{`Encrypted since ${formatTime(keys.pluginSecrets.encryptedAt)}.`}</p>
439
+ ) : (
440
+ <>
441
+ <p className="text-sm mb-2">
442
+ Not encrypted yet. Secret settings of plugins are saved as plain text until every server pod
443
+ runs this release and the first encryption run has finished.
444
+ </p>
445
+ {keys.pluginSecrets?.reason && (
446
+ <p className="text-sm text-text-muted">{keys.pluginSecrets.reason}</p>
447
+ )}
448
+ </>
449
+ )}
450
+ </Card>
451
+
452
+ <Modal open={confirming === "compromised"} onClose={closeDialog} title="Key compromised">
453
+ <p className="text-sm mb-3">
454
+ {running
455
+ ? "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."
456
+ : "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."}
457
+ </p>
458
+ <p className="text-sm mb-3">
459
+ If the keyring was exposed together with a copy of the database, the saved values were exposed too:
460
+ afterwards, replace the credentials at their providers and enter the new ones here.
461
+ </p>
462
+ {error && <Alert>{error}</Alert>}
463
+ <div className="flex gap-3 justify-end mt-5">
464
+ <Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={closeDialog}>
465
+ Back
466
+ </Button>
467
+ <Button
468
+ type="button"
469
+ className="!w-auto"
470
+ loading={busy}
471
+ disabled={busy}
472
+ onClick={() =>
473
+ void run(
474
+ () => rotateConfigKey({ compromised: true }),
475
+ "Could not start the rotation.",
476
+ "A rotation was already in progress and is already marked as a key compromise; its progress is shown."
477
+ )
478
+ }
479
+ >
480
+ Rotate now
481
+ </Button>
482
+ </div>
483
+ </Modal>
484
+
485
+ <Modal open={confirming === "activate"} onClose={closeDialog} title="Activate the new key anyway">
486
+ {staleCopies.length > 0 ? (
487
+ <>
488
+ <p className="text-sm mb-2">
489
+ The new key encrypts from now on, before every server copy has loaded it. These copies still
490
+ encrypt with the old key, which every copy can read, but they can&rsquo;t read values saved
491
+ with the new key until they are replaced:
492
+ </p>
493
+ <CopyList label="Copies that will not be able to read new values" copies={staleCopies} />
494
+ <p className="text-sm mb-3">
495
+ Saved values are re-encrypted only once those copies are gone or have loaded the key. Do
496
+ this only if they are being replaced.
497
+ </p>
498
+ </>
499
+ ) : (
500
+ <p className="text-sm mb-3">
501
+ No copy is waiting for the new key now; it is activated on its own within a moment, so there is
502
+ nothing to force.
503
+ </p>
504
+ )}
505
+ {error && <Alert>{error}</Alert>}
506
+ <div className="flex gap-3 justify-end mt-5">
507
+ <Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={closeDialog}>
508
+ Back
509
+ </Button>
510
+ <Button
511
+ type="button"
512
+ className="!w-auto"
513
+ loading={busy}
514
+ disabled={busy || !newKid}
515
+ onClick={() =>
516
+ void run(
517
+ () => activateConfigKey(newKid!),
518
+ "Could not activate the new key.",
519
+ "The new key is no longer waiting to be activated; the rotation's progress is shown."
520
+ )
521
+ }
522
+ >
523
+ Activate anyway
524
+ </Button>
525
+ </div>
526
+ </Modal>
527
+ </>
528
+ );
529
+ }
@@ -0,0 +1,37 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { ConfigKeyRotation, ConfigKeys } from "../../../../../lib/admin/configKeysApi.js";
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+
9
+ /** Whole days from `iso` to `now`, as the age of a key. */
10
+ export function ageText(iso: string, now: number): string {
11
+ const days = Math.max(0, Math.floor((now - new Date(iso).getTime()) / DAY_MS));
12
+ return days < 1 ? "less than a day" : days === 1 ? "1 day" : `${days} days`;
13
+ }
14
+
15
+ /**
16
+ * 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
17
+ * 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.
18
+ */
19
+ export function activeKidOf(keys: ConfigKeys): string | null {
20
+ const rotation = keys.rotation;
21
+ if (rotation?.kid && rotation.activatedAt) {
22
+ return rotation.kid;
23
+ }
24
+ if (rotation?.kid && (rotation.phase === "adding" || rotation.phase === "activating")) {
25
+ return (
26
+ keys.keys.find((key) => key.active && key.kid !== rotation.kid)?.kid ??
27
+ keys.keys.find((key) => key.kid !== rotation.kid)?.kid ??
28
+ null
29
+ );
30
+ }
31
+ return keys.keys.find((key) => key.active)?.kid ?? keys.activeKid;
32
+ }
33
+
34
+ /** Whether `rotation` is still going: a finished or failed one is only shown. */
35
+ export function isRunning(rotation: ConfigKeyRotation | null | undefined): rotation is ConfigKeyRotation {
36
+ return !!rotation && rotation.phase !== "done" && rotation.phase !== "failed";
37
+ }
@@ -13,9 +13,11 @@ import {
13
13
  getDiagnosticsVersions,
14
14
  } from "./diagnosticsApi.js";
15
15
  import { saveTextFile, SaveFile, timestampedFilename } from "./download.js";
16
+ import { activeKidOf } from "../configEncryption/keyringParts.js";
16
17
  import { describeError } from "./format.js";
17
18
  import InformationPanel from "./InformationPanel.js";
18
19
  import { loadInstalledPlugins } from "./InstalledPlugins.js";
20
+ import { loadKeyring } from "./KeyringCard.js";
19
21
  import type { LogSocketFactory } from "./logClient.js";
20
22
  import LogsPanel from "./LogsPanel.js";
21
23
  import RuntimePanel from "./RuntimePanel.js";
@@ -57,6 +59,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
57
59
  const information = useDiagnosticsResource(getDiagnosticsInformation, reloadKey, "Could not read the server's environment and configuration.");
58
60
  const runtime = useDiagnosticsResource(getDiagnosticsRuntime, reloadKey, "Could not read the runtime.");
59
61
  const plugins = useDiagnosticsResource(loadInstalledPlugins, reloadKey, "Could not read the installed plugins.");
62
+ const keyring = useDiagnosticsResource(loadKeyring, reloadKey, "Could not read the configuration keyring.");
60
63
  const polling = useMetricsPolling(tab === "system");
61
64
  const logStream = useLogStream({ createSocket: createLogSocket });
62
65
  const logsOpened = useRef(false);
@@ -69,7 +72,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
69
72
  }
70
73
  }, [tab]);
71
74
 
72
- const refreshing = versions.loading || information.loading || runtime.loading || plugins.loading;
75
+ const refreshing = versions.loading || information.loading || runtime.loading || plugins.loading || keyring.loading;
73
76
 
74
77
  function handleTabKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
75
78
  const index = TABS.findIndex((item) => item.id === tab);
@@ -91,8 +94,8 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
91
94
  }
92
95
 
93
96
  /**
94
- * Saves the versions, the environment and configuration (the server has already withheld every secret), the runtime and the
95
- * latest metrics sample (asking for one if the System tab has not sampled): no logs.
97
+ * Saves the versions, the environment and configuration (the server has already withheld every secret), the runtime, the
98
+ * keyring's key ids, ages and counts and the latest metrics sample (asking for one if the System tab has not sampled): no logs.
96
99
  */
97
100
  async function downloadReport() {
98
101
  setPreparingReport(true);
@@ -118,12 +121,25 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
118
121
  status: plugins.data.status ?? null,
119
122
  }
120
123
  : null,
124
+ // 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.
125
+ keyring: keyring.data
126
+ ? {
127
+ activeKid: activeKidOf({ ...keyring.data, keys: keyring.data.keys ?? [] }),
128
+ countedAt: keyring.data.countedAt ?? null,
129
+ keys: (keyring.data.keys ?? []).map(({ kid, createdAt, envelopes }) => ({ kid, createdAt, envelopes })),
130
+ rotationPhase: keyring.data.rotation?.phase ?? null,
131
+ // As of the re-encryption pass that made the list: a value entered again stays counted until a later pass.
132
+ unreadableValues: keyring.data.reencryption?.unreadable?.length ?? 0,
133
+ unreadableAsOf: keyring.data.reencryption?.finishedAt ?? null,
134
+ }
135
+ : null,
121
136
  metrics: metrics ?? null,
122
137
  errors: {
123
138
  versions: versions.error ?? null,
124
139
  information: information.error ?? null,
125
140
  runtime: runtime.error ?? null,
126
141
  plugins: plugins.error ?? null,
142
+ keyring: keyring.error ?? null,
127
143
  metrics: metricsError ?? null,
128
144
  },
129
145
  };
@@ -175,7 +191,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
175
191
  </div>
176
192
 
177
193
  <div role="tabpanel" id={panelId(tab)} aria-labelledby={tabId(tab)}>
178
- {tab === "information" && <InformationPanel versions={versions} plugins={plugins} information={information} />}
194
+ {tab === "information" && <InformationPanel versions={versions} plugins={plugins} information={information} keyring={keyring} />}
179
195
  {tab === "runtime" && <RuntimePanel runtime={runtime} />}
180
196
  {tab === "system" && <SystemPanel polling={polling} />}
181
197
  {tab === "logs" && <LogsPanel stream={logStream} saveFile={saveFile} />}