@rapidmx/web-client 0.19.0 → 0.21.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 (32) hide show
  1. package/README.md +1 -1
  2. package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
  3. package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
  4. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
  6. package/apps/shared/components/admin/layout/AdminShell.tsx +2 -2
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +508 -35
  8. package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
  9. package/apps/shared/components/escrow/layout/EscrowShell.tsx +2 -2
  10. package/apps/shared/components/layout/AppShell.tsx +4 -2
  11. package/apps/shared/components/mail/ConversationList.tsx +18 -9
  12. package/apps/shared/components/mail/compose/ComposeWindow.tsx +24 -0
  13. package/apps/shared/components/mail/listPreferences.ts +3 -3
  14. package/apps/www/index.tsx +62 -12
  15. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
  16. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
  17. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
  18. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
  19. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
  20. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  21. package/dist/apps/shared/components/admin/settings/PluginsManager.js +267 -35
  22. package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
  23. package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
  24. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +2 -2
  25. package/dist/apps/shared/components/layout/AppShell.js +4 -2
  26. package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
  27. package/dist/apps/shared/components/mail/ConversationList.js +9 -5
  28. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
  29. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +20 -1
  30. package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
  31. package/dist/apps/www/index.js +51 -10
  32. package/package.json +2 -2
@@ -11,6 +11,7 @@ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
11
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
12
12
  import { notify } from "../../../notifications/store.js";
13
13
  import { isElevationRequired } from "../elevation.js";
14
+ import { getAllowPrerelease, setAllowPrerelease } from "./pluginPreferences.js";
14
15
  const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
16
  /** How often status is refreshed while server copies are still applying a change. */
16
17
  const PENDING_POLL_MS = 5000;
@@ -43,6 +44,45 @@ function purgeFailure(purge) {
43
44
  }
44
45
  /** A day, in the administrator's own format. */
45
46
  const formatDate = (iso) => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
47
+ const BULK_WORDS = {
48
+ upgrade: { doing: "Upgrading", done: "upgraded" },
49
+ disable: { doing: "Disabling", done: "disabled" },
50
+ uninstall: { doing: "Uninstalling", done: "uninstalled" },
51
+ };
52
+ const pluralPlugins = (count) => `${count} ${count === 1 ? "plugin" : "plugins"}`;
53
+ /**
54
+ * `plugins` in the order a bulk action has to change them in, since a plugin that others require can't be disabled or
55
+ * uninstalled while they're enabled, and one that requires others may need them at their new versions first: each
56
+ * plugin comes after the ones it requires (`dependenciesFirst`, for an upgrade) or before them (for the rest).
57
+ */
58
+ function orderByRequirements(plugins, dependenciesFirst) {
59
+ const byName = new Map(plugins.map((plugin) => [plugin.name, plugin]));
60
+ const ordered = [];
61
+ const seen = new Set();
62
+ const visit = (plugin) => {
63
+ if (seen.has(plugin.name)) {
64
+ return;
65
+ }
66
+ seen.add(plugin.name);
67
+ for (const required of Object.keys(plugin.manifest.requires ?? {})) {
68
+ const dependency = byName.get(required);
69
+ if (dependency) {
70
+ visit(dependency);
71
+ }
72
+ }
73
+ ordered.push(plugin);
74
+ };
75
+ plugins.forEach(visit);
76
+ return dependenciesFirst ? ordered : ordered.reverse();
77
+ }
78
+ /**
79
+ * The bar that runs across the top of the window while plugins are being changed, so it's plain that something is still
80
+ * happening: it slides endlessly, since how long a registry lookup or an install takes isn't known. It stands still
81
+ * (as a faded full bar) when the user asks for less motion.
82
+ */
83
+ function PluginProgressBar({ label }) {
84
+ return (_jsx("div", { role: "progressbar", "aria-label": label, className: "fixed inset-x-0 top-0 z-[1100] h-1 overflow-hidden bg-primary/20", children: _jsx("div", { className: "rr-progress-indeterminate h-full w-1/3 rounded-pill bg-primary motion-reduce:w-full motion-reduce:opacity-40" }) }));
85
+ }
46
86
  /** Classes for a plugin table's header and body cells. */
47
87
  const TH_CLASS = "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border";
48
88
  const TD_CLASS = "py-3 px-2.5 border-b border-border align-top";
@@ -80,6 +120,21 @@ export default function PluginsManager({ embedded = false } = {}) {
80
120
  const [listStale, setListStale] = useState(false);
81
121
  /** How many times status has been retried while none has ever been read. */
82
122
  const [statusRetries, setStatusRetries] = useState(0);
123
+ /** Whether prerelease versions are offered (a preference of this browser). */
124
+ const [allowPrerelease, setAllowPrereleaseState] = useState(getAllowPrerelease);
125
+ /** The same, for `refreshUpdates()`, which must stay the same function for the effects that depend on it. */
126
+ const allowPrereleaseRef = useRef(allowPrerelease);
127
+ /** Identifies the latest update check, so a slower earlier one can't replace what it found. */
128
+ const updatesRequest = useRef(0);
129
+ /** The plugins ticked for a bulk action. */
130
+ const [selected, setSelected] = useState(new Set());
131
+ /** Set while a bulk action runs. */
132
+ const [bulk, setBulk] = useState(null);
133
+ const [bulkFailure, setBulkFailure] = useState(null);
134
+ /** The plugins waiting for the administrator to confirm uninstalling them together. */
135
+ const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState(null);
136
+ /** How many changes (or the lookups that come before them) are waiting on the server. */
137
+ const [working, setWorking] = useState(0);
83
138
  const statusRequest = useRef(null);
84
139
  /** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
85
140
  const seenPurges = useRef(new Map());
@@ -107,12 +162,41 @@ export default function PluginsManager({ embedded = false } = {}) {
107
162
  }));
108
163
  return statusRequest.current;
109
164
  }, []);
165
+ /** Checks the registry for newer versions. Resolves what it found, or `undefined` when it couldn't check. */
110
166
  const refreshUpdates = useCallback(() => {
167
+ const token = ++updatesRequest.current;
111
168
  // Also advisory: a registry that can't be reached just means no update badges.
112
- return getPluginUpdates()
113
- .then((list) => setUpdates(new Map(list.map((info) => [info.uid, info]))))
114
- .catch(() => setUpdates(new Map()));
169
+ return getPluginUpdates({ prerelease: allowPrereleaseRef.current })
170
+ .then((list) => {
171
+ if (token === updatesRequest.current) {
172
+ setUpdates(new Map(list.map((info) => [info.uid, info])));
173
+ }
174
+ return list;
175
+ })
176
+ .catch(() => {
177
+ if (token === updatesRequest.current) {
178
+ setUpdates(new Map());
179
+ }
180
+ return undefined;
181
+ });
115
182
  }, []);
183
+ /** Keeps `work` counted as in progress, which is what the bar across the top of the window shows. */
184
+ const track = useCallback(async (work) => {
185
+ setWorking((count) => count + 1);
186
+ try {
187
+ return await work;
188
+ }
189
+ finally {
190
+ setWorking((count) => count - 1);
191
+ }
192
+ }, []);
193
+ function changePrerelease(allow) {
194
+ setAllowPrerelease(allow);
195
+ allowPrereleaseRef.current = allow;
196
+ setAllowPrereleaseState(allow);
197
+ // What counts as an update depends on it.
198
+ void refreshUpdates();
199
+ }
116
200
  /** Re-reads the list, for changes that also installed or enabled other plugins. */
117
201
  const reload = useCallback(() => {
118
202
  return listPlugins()
@@ -210,10 +294,14 @@ export default function PluginsManager({ embedded = false } = {}) {
210
294
  // A change waiting for confirmation keeps the row busy by itself, until the confirmation closes.
211
295
  setBusy(plugin.uid, false);
212
296
  }
297
+ /** Disables an installed plugin, resolving why it couldn't be, or `null`. */
298
+ function disable(plugin) {
299
+ return attempt(async () => applied(await track(updatePlugin(plugin.uid, { version: plugin.version, enabled: false }))), `Could not disable ${plugin.manifest.displayName}.`);
300
+ }
213
301
  function toggle(plugin) {
214
302
  const displayName = plugin.manifest.displayName;
215
303
  if (plugin.enabled) {
216
- return onRow(plugin, () => attempt(async () => applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: false })), `Could not disable ${displayName}.`));
304
+ return onRow(plugin, () => disable(plugin));
217
305
  }
218
306
  // Enabling a plugin also installs or enables the plugins it requires, so it's previewed like any other change.
219
307
  // The installed version is planned from its stored manifest, so this doesn't need the registry. A preview that
@@ -221,7 +309,7 @@ export default function PluginsManager({ embedded = false } = {}) {
221
309
  // saw - so the failure is shown with a retry instead.
222
310
  return onRow(plugin, async () => {
223
311
  const problem = await planned(plugin.name, plugin.packageVersion, displayName, async (plan) => {
224
- applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: true, expectedPlan: expectedPlanOf(plan) }));
312
+ applied(await track(updatePlugin(plugin.uid, { version: plugin.version, enabled: true, expectedPlan: expectedPlanOf(plan) })));
225
313
  if (plan.install.length > 0 || plan.enable.length > 0) {
226
314
  void reload();
227
315
  }
@@ -239,7 +327,7 @@ export default function PluginsManager({ embedded = false } = {}) {
239
327
  async function planned(name, packageVersion, displayName, apply, failure, options = {}) {
240
328
  let plan;
241
329
  try {
242
- plan = await planPluginChange(name, packageVersion);
330
+ plan = await track(planPluginChange(name, packageVersion));
243
331
  }
244
332
  catch (err) {
245
333
  return errorMessage(err, failure);
@@ -248,6 +336,9 @@ export default function PluginsManager({ embedded = false } = {}) {
248
336
  return `${displayName} ${plan.plugin.version} can't be installed. ${plan.conflicts.join(" ")}`;
249
337
  }
250
338
  if (plan.install.length > 0 || plan.enable.length > 0) {
339
+ if (options.unattended) {
340
+ return `${displayName} ${plan.plugin.version} also needs other plugins installed or enabled, so it wasn't changed. Change it on its own to review them.`;
341
+ }
251
342
  setConfirming({ displayName, plan, apply, failure, uid: options.uid });
252
343
  return null;
253
344
  }
@@ -255,7 +346,7 @@ export default function PluginsManager({ embedded = false } = {}) {
255
346
  }
256
347
  function install(name, packageVersion, displayName) {
257
348
  return planned(name, packageVersion, displayName, async (plan) => {
258
- const result = await addPlugin(name, packageVersion, expectedPlanOf(plan));
349
+ const result = await track(addPlugin(name, packageVersion, expectedPlanOf(plan)));
259
350
  applied(...result.dependencies, result.plugin);
260
351
  // Adding a plugin cancels the deletion of its data that was waiting for the servers to stop running it.
261
352
  for (const warning of result.warnings ?? []) {
@@ -263,18 +354,18 @@ export default function PluginsManager({ embedded = false } = {}) {
263
354
  }
264
355
  }, `Could not install ${displayName}.`);
265
356
  }
266
- function changeVersion(plugin, packageVersion) {
357
+ function changeVersion(plugin, packageVersion, unattended = false) {
267
358
  const failure = `Could not upgrade ${plugin.manifest.displayName}.`;
268
359
  if (!plugin.enabled) {
269
360
  // A disabled plugin stays disabled, so nothing it requires is installed or enabled - there's nothing to preview.
270
- return attempt(async () => applied(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion })), failure);
361
+ return attempt(async () => applied(await track(updatePlugin(plugin.uid, { version: plugin.version, packageVersion }))), failure);
271
362
  }
272
363
  return planned(plugin.name, packageVersion, plugin.manifest.displayName, async (plan) => {
273
- applied(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion, expectedPlan: expectedPlanOf(plan) }));
364
+ applied(await track(updatePlugin(plugin.uid, { version: plugin.version, packageVersion, expectedPlan: expectedPlanOf(plan) })));
274
365
  if (plan.install.length > 0 || plan.enable.length > 0) {
275
366
  void reload();
276
367
  }
277
- }, failure, { uid: plugin.uid });
368
+ }, failure, { uid: plugin.uid, unattended });
278
369
  }
279
370
  function upgrade(plugin, packageVersion) {
280
371
  return onRow(plugin, () => changeVersion(plugin, packageVersion));
@@ -285,7 +376,7 @@ export default function PluginsManager({ embedded = false } = {}) {
285
376
  return onRow(purge, async () => {
286
377
  let latest;
287
378
  try {
288
- latest = (await planPluginChange(purge.name)).plugin.version;
379
+ latest = (await track(planPluginChange(purge.name, undefined, { prerelease: allowPrerelease }))).plugin.version;
289
380
  }
290
381
  catch (err) {
291
382
  return errorMessage(err, `Could not install ${displayName}.`);
@@ -309,35 +400,157 @@ export default function PluginsManager({ embedded = false } = {}) {
309
400
  }
310
401
  setBusy(purge.uid, false);
311
402
  }
403
+ /** Lists a data deletion the server just scheduled. Shown at once; the status polling that follows replaces it with
404
+ * what the servers report. */
405
+ function showScheduledPurge(scheduled) {
406
+ setStatus((prev) => prev && scheduled ? { ...prev, purges: [...(prev.purges ?? []).filter((purge) => purge.uid !== scheduled.uid), scheduled] } : prev);
407
+ }
408
+ /** Uninstalls an installed plugin, and with `purgeData` deletes the data it stored once no server runs it. Resolves
409
+ * why it couldn't be, or `null`. */
410
+ async function uninstall(plugin, purgeData) {
411
+ try {
412
+ const result = await removePlugin(plugin.uid, { purgeData });
413
+ setPlugins((prev) => prev.filter((other) => other.uid !== plugin.uid));
414
+ if (result.purgeScheduled) {
415
+ showScheduledPurge(result.purge);
416
+ }
417
+ return null;
418
+ }
419
+ catch (err) {
420
+ return isElevationRequired(err) ? ELEVATION_MESSAGE : errorMessage(err, `Could not uninstall ${plugin.manifest.displayName}.`);
421
+ }
422
+ }
423
+ /**
424
+ * Applies `kind` to `targets` one after another, in the order their requirements allow, and says what became of
425
+ * them. A plugin that can't be changed is left as it was and the rest carry on. For an upgrade, `versions` holds the
426
+ * version each plugin goes to; for an uninstall, `purgeData` also deletes what each stored. Deleting data needs the
427
+ * administrator to have confirmed their identity recently, which no plugin will get past if one is refused for it,
428
+ * so the rest aren't tried.
429
+ */
430
+ async function runBulk(kind, targets, options = {}) {
431
+ const { versions = new Map(), purgeData = false } = options;
432
+ const ordered = orderByRequirements(targets, kind === "upgrade");
433
+ const reasons = [];
434
+ const failed = new Set();
435
+ setError(null);
436
+ setRetryEnableUid(null);
437
+ setBulkFailure(null);
438
+ await track((async () => {
439
+ for (const [index, plugin] of ordered.entries()) {
440
+ setBulk({ kind, done: index, total: ordered.length, current: plugin.manifest.displayName });
441
+ const problem = kind === "upgrade"
442
+ ? await changeVersion(plugin, versions.get(plugin.uid), true)
443
+ : kind === "disable"
444
+ ? await disable(plugin)
445
+ : await uninstall(plugin, purgeData);
446
+ if (!problem) {
447
+ continue;
448
+ }
449
+ failed.add(plugin.uid);
450
+ reasons.push(`${plugin.manifest.displayName}: ${problem}`);
451
+ if (problem === ELEVATION_MESSAGE) {
452
+ for (const untried of ordered.slice(index + 1)) {
453
+ failed.add(untried.uid);
454
+ reasons.push(`${untried.manifest.displayName}: Not attempted.`);
455
+ }
456
+ break;
457
+ }
458
+ }
459
+ })());
460
+ setBulk(null);
461
+ setSelected(failed);
462
+ const succeeded = ordered.length - failed.size;
463
+ if (kind === "uninstall" && succeeded > 0) {
464
+ watchRollout();
465
+ }
466
+ if (succeeded > 0) {
467
+ notify({
468
+ kind: "success",
469
+ title: `${pluralPlugins(succeeded)} ${BULK_WORDS[kind].done}`,
470
+ message: purgeData ? "Their data is deleted once every server has stopped running them. Adding a plugin again before then cancels its deletion." : undefined,
471
+ });
472
+ }
473
+ if (reasons.length > 0) {
474
+ setBulkFailure({ title: `${pluralPlugins(reasons.length)} couldn't be ${BULK_WORDS[kind].done}:`, reasons });
475
+ }
476
+ }
477
+ /** Upgrades every plugin that has an update, whatever is ticked. Checks the registry first, so it's never working
478
+ * from an out-of-date list of updates. */
479
+ async function upgradeAll() {
480
+ setError(null);
481
+ setBulkFailure(null);
482
+ const found = await track(refreshUpdates());
483
+ if (!found) {
484
+ setError("Could not check for plugin updates.");
485
+ return;
486
+ }
487
+ const versions = new Map();
488
+ for (const info of found) {
489
+ if (info.updateAvailable && info.latestVersion) {
490
+ versions.set(info.uid, info.latestVersion);
491
+ }
492
+ }
493
+ const targets = plugins.filter((plugin) => versions.has(plugin.uid));
494
+ if (targets.length === 0) {
495
+ const unchecked = found.filter((info) => info.error).length;
496
+ notify(unchecked > 0
497
+ ? { kind: "warning", title: "No updates found", message: `${pluralPlugins(unchecked)} couldn't be checked against the registry.` }
498
+ : { kind: "info", title: "All plugins are up to date" });
499
+ return;
500
+ }
501
+ await runBulk("upgrade", targets, { versions });
502
+ }
503
+ const toggleSelected = (uid) => setSelected((prev) => {
504
+ const next = new Set(prev);
505
+ if (!next.delete(uid)) {
506
+ next.add(uid);
507
+ }
508
+ return next;
509
+ });
510
+ const selectedPlugins = plugins.filter((plugin) => selected.has(plugin.uid));
511
+ const allSelected = plugins.length > 0 && selectedPlugins.length === plugins.length;
512
+ const selectedUpgrades = selectedPlugins.filter((plugin) => updates.get(plugin.uid)?.updateAvailable);
513
+ const selectedEnabled = selectedPlugins.filter((plugin) => plugin.enabled);
312
514
  // Retried against the plugin as it's listed now (a newer version, or already enabled elsewhere, hides the retry).
313
515
  const retryEnable = plugins.find((plugin) => plugin.uid === retryEnableUid && !plugin.enabled);
314
516
  const displayNameOf = (name) => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
315
517
  const SectionHeading = embedded ? "h3" : "h2";
316
518
  const addButton = (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setAdding(true), children: "Add by name" }));
317
- return (_jsxs(_Fragment, { children: [embedded ? (_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-3xl", children: "Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Plugins" }), addButton] }), _jsx("p", { className: "text-sm text-text-muted mb-6 max-w-3xl", children: "Plugins add protocols and features to every server. Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })] })), error && (_jsxs(Alert, { children: [error, retryEnable && (_jsxs(_Fragment, { children: [" ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0", onClick: () => void toggle(retryEnable), children: "Try again" })] }))] })), _jsx(RolloutBanner, { status: status }), statusStale && status && (_jsx("p", { className: "mb-4 text-xs text-text-muted", children: "Couldn't refresh server status. Showing the last status reported." })), listStale && (_jsxs("p", { className: "mb-4 text-xs text-text-muted", children: ["Couldn't re-read the plugin list, so it may be out of date.", " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0 !text-xs", onClick: () => void reload(), children: "Retry" })] })), _jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide", children: "Installed plugins" }), embedded && addButton] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : plugins.length === 0 && purges.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No plugins installed." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { role: "table", className: "w-full text-sm border-collapse block lg:table", children: [_jsx("thead", { role: "rowgroup", className: "hidden lg:table-header-group", children: _jsxs("tr", { role: "row", children: [_jsx("th", { role: "columnheader", className: TH_CLASS, children: "Plugin" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Version" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Status" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: _jsx("span", { className: "sr-only", children: "Actions" }) })] }) }), _jsxs("tbody", { role: "rowgroup", className: "block lg:table-row-group", children: [plugins.map((plugin) => {
318
- const update = updates.get(plugin.uid);
319
- const latest = update?.updateAvailable ? update.latestVersion : undefined;
320
- const busy = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid;
321
- const requires = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
322
- const requiredBy = plugins
323
- .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
324
- .map((other) => other.manifest.displayName);
325
- return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
326
- }), purges.map((purge) => (_jsx(UninstalledPluginRow, { purge: purge, busy: busyUids.has(purge.uid), onRetry: () => void retryPurge(purge), onInstall: () => void reinstall(purge) }, purge.uid)))] })] }) })), _jsx(PluginBrowser, { headingLevel: SectionHeading, plugins: plugins, onInstall: (result) => install(result.name, result.version, result.name),
519
+ const bulkBusy = bulk !== null;
520
+ return (_jsxs(_Fragment, { children: [(working > 0 || pending) && _jsx(PluginProgressBar, { label: working > 0 ? "Working on plugins" : "Applying plugin changes" }), embedded ? (_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-3xl", children: "Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Plugins" }), addButton] }), _jsx("p", { className: "text-sm text-text-muted mb-6 max-w-3xl", children: "Plugins add protocols and features to every server. Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })] })), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-5 max-w-3xl cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 h-4 w-4 shrink-0", checked: allowPrerelease, onChange: (e) => changePrerelease(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: "Allow pre-release versions" }), _jsx("span", { className: "text-xs text-text-muted", children: "Also offer beta and other pre-release builds, such as 1.0.0-beta.2, as updates and as versions to install. They can be unstable. This is remembered in this browser." })] })] }), bulkFailure && (_jsx(Alert, { children: _jsxs("div", { children: [bulkFailure.title, _jsx("ul", { className: "list-disc pl-5 mt-1", children: bulkFailure.reasons.map((reason) => (_jsx("li", { className: "break-words", children: reason }, reason))) })] }) })), error && (_jsxs(Alert, { children: [error, retryEnable && (_jsxs(_Fragment, { children: [" ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0", onClick: () => void toggle(retryEnable), children: "Try again" })] }))] })), _jsx(RolloutBanner, { status: status }), statusStale && status && (_jsx("p", { className: "mb-4 text-xs text-text-muted", children: "Couldn't refresh server status. Showing the last status reported." })), listStale && (_jsxs("p", { className: "mb-4 text-xs text-text-muted", children: ["Couldn't re-read the plugin list, so it may be out of date.", " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0 !text-xs", onClick: () => void reload(), children: "Retry" })] })), _jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide", children: "Installed plugins" }), embedded && addButton] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : plugins.length === 0 && purges.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No plugins installed." })) : (_jsxs("div", { className: "overflow-x-auto", children: [plugins.length > 0 && (_jsxs("div", { role: "toolbar", "aria-label": "Actions for several plugins", className: "flex flex-wrap items-center gap-2 py-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm mr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "h-4 w-4 shrink-0", "aria-label": "Select all plugins", checked: allSelected, disabled: bulkBusy, ref: (input) => {
521
+ if (input) {
522
+ input.indeterminate = selectedPlugins.length > 0 && !allSelected;
523
+ }
524
+ }, onChange: (e) => setSelected(e.target.checked ? new Set(plugins.map((plugin) => plugin.uid)) : new Set()) }), _jsx("span", { children: selectedPlugins.length > 0 ? `${selectedPlugins.length} selected` : "Select all" })] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Upgrade selected plugins", disabled: bulkBusy || selectedUpgrades.length === 0, onClick: () => void runBulk("upgrade", selectedUpgrades, {
525
+ versions: new Map(selectedUpgrades.map((plugin) => [plugin.uid, updates.get(plugin.uid).latestVersion])),
526
+ }), children: ["Upgrade", selectedUpgrades.length > 0 ? ` (${selectedUpgrades.length})` : ""] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Disable selected plugins", disabled: bulkBusy || selectedEnabled.length === 0, onClick: () => void runBulk("disable", selectedEnabled), children: ["Disable", selectedEnabled.length > 0 ? ` (${selectedEnabled.length})` : ""] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Uninstall selected plugins", disabled: bulkBusy || selectedPlugins.length === 0, onClick: () => setConfirmingBulkUninstall(selectedPlugins), children: ["Uninstall", selectedPlugins.length > 0 ? ` (${selectedPlugins.length})` : ""] }), _jsx(Button, { type: "button", className: "!w-auto ml-auto", "aria-label": "Upgrade all plugins", loading: bulk?.kind === "upgrade", disabled: bulkBusy, onClick: () => void upgradeAll(), children: "Upgrade all" }), bulk && (_jsxs("span", { "aria-live": "polite", className: "basis-full text-xs text-text-muted", children: [BULK_WORDS[bulk.kind].doing, " ", bulk.current, " (", bulk.done + 1, " of ", bulk.total, ")\u2026"] }))] })), _jsxs("table", { role: "table", className: "w-full text-sm border-collapse block lg:table", children: [_jsx("thead", { role: "rowgroup", className: "hidden lg:table-header-group", children: _jsxs("tr", { role: "row", children: [_jsx("th", { role: "columnheader", className: TH_CLASS, children: "Plugin" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Version" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Status" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: _jsx("span", { className: "sr-only", children: "Actions" }) })] }) }), _jsxs("tbody", { role: "rowgroup", className: "block lg:table-row-group", children: [plugins.map((plugin) => {
527
+ const update = updates.get(plugin.uid);
528
+ const latest = update?.updateAvailable ? update.latestVersion : undefined;
529
+ const busy = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
530
+ const requires = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
531
+ const requiredBy = plugins
532
+ .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
533
+ .map((other) => other.manifest.displayName);
534
+ return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("input", { type: "checkbox", className: "mt-1 h-4 w-4 shrink-0", "aria-label": `Select ${plugin.manifest.displayName}`, checked: selected.has(plugin.uid), disabled: bulkBusy, onChange: () => toggleSelected(plugin.uid) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] })] }) }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
535
+ }), purges.map((purge) => (_jsx(UninstalledPluginRow, { purge: purge, busy: busyUids.has(purge.uid), onRetry: () => void retryPurge(purge), onInstall: () => void reinstall(purge) }, purge.uid)))] })] })] })), _jsx(PluginBrowser, { headingLevel: SectionHeading, plugins: plugins, prerelease: allowPrerelease, onInstall: (result) => install(result.name, result.version, result.name),
327
536
  // The browser only offers an upgrade for a result it matched to one of these same `plugins`.
328
- onUpgrade: (uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid), packageVersion) }), _jsx(AddPluginModal, { open: adding, onClose: () => setAdding(false), onAdd: async (name, packageVersion, displayName) => {
537
+ onUpgrade: (uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid), packageVersion) }), _jsx(AddPluginModal, { open: adding, prerelease: allowPrerelease, onClose: () => setAdding(false), onAdd: async (name, packageVersion, displayName) => {
329
538
  const problem = await install(name, packageVersion, displayName);
330
539
  if (!problem) {
331
540
  setAdding(false);
332
541
  }
333
542
  return problem;
334
- } }), upgrading && (_jsx(ChangeVersionModal, { plugin: upgrading, onClose: () => setUpgrading(null), onSave: async (packageVersion) => {
543
+ } }), upgrading && (_jsx(ChangeVersionModal, { plugin: upgrading, prerelease: allowPrerelease, onClose: () => setUpgrading(null), onSave: async (packageVersion) => {
335
544
  const problem = await changeVersion(upgrading, packageVersion);
336
545
  if (!problem) {
337
546
  setUpgrading(null);
338
547
  }
339
548
  return problem;
340
- } })), confirming && (_jsx(ConfirmDependenciesModal, { change: confirming, displayNameOf: displayNameOf, onClose: () => setConfirming(null), onDone: () => setConfirming(null) })), configuring && (_jsx(SettingsModal, { plugin: configuring, onClose: () => setConfiguring(null), onSaved: (plugin) => {
549
+ } })), confirming && (_jsx(ConfirmDependenciesModal, { change: confirming, displayNameOf: displayNameOf, onClose: () => setConfirming(null), onDone: () => setConfirming(null) })), confirmingBulkUninstall && (_jsx(BulkUninstallModal, { plugins: confirmingBulkUninstall, onClose: () => setConfirmingBulkUninstall(null), onConfirm: (purgeData) => {
550
+ const targets = confirmingBulkUninstall;
551
+ setConfirmingBulkUninstall(null);
552
+ void runBulk("uninstall", targets, { purgeData });
553
+ } })), configuring && (_jsx(SettingsModal, { plugin: configuring, onClose: () => setConfiguring(null), onSaved: (plugin) => {
341
554
  setConfiguring(null);
342
555
  applied(plugin);
343
556
  } })), removing && (_jsx(RemoveModal, { plugin: removing, onClose: () => setRemoving(null), onRemoved: (result) => {
@@ -346,9 +559,7 @@ export default function PluginsManager({ embedded = false } = {}) {
346
559
  setRemoving(null);
347
560
  setPlugins((prev) => prev.filter((plugin) => plugin.uid !== uid));
348
561
  if (result.purgeScheduled) {
349
- // Shown at once; the status polling that follows replaces it with what the servers report.
350
- const scheduled = result.purge;
351
- setStatus((prev) => prev && scheduled ? { ...prev, purges: [...(prev.purges ?? []).filter((purge) => purge.uid !== scheduled.uid), scheduled] } : prev);
562
+ showScheduledPurge(result.purge);
352
563
  notify({
353
564
  kind: "info",
354
565
  title: `${displayName} uninstalled`,
@@ -360,7 +571,7 @@ export default function PluginsManager({ embedded = false } = {}) {
360
571
  }
361
572
  const ALL_NAMESPACES = "";
362
573
  /** Searches the configured namespaces' registries for plugin packages, with install and upgrade actions. */
363
- function PluginBrowser({ headingLevel: Heading, plugins, onInstall, onUpgrade, }) {
574
+ function PluginBrowser({ headingLevel: Heading, plugins, prerelease, onInstall, onUpgrade, }) {
364
575
  const [namespaces, setNamespaces] = useState([]);
365
576
  const [namespace, setNamespace] = useState(ALL_NAMESPACES);
366
577
  const [results, setResults] = useState(null);
@@ -372,12 +583,14 @@ function PluginBrowser({ headingLevel: Heading, plugins, onInstall, onUpgrade, }
372
583
  .then(setNamespaces)
373
584
  .catch(() => setNamespaces([]));
374
585
  }, []);
586
+ // What was found was found under the other setting, so it isn't shown as though it still held.
587
+ useEffect(() => setResults(null), [prerelease]);
375
588
  async function search(e) {
376
589
  e.preventDefault();
377
590
  setSearching(true);
378
591
  setError(null);
379
592
  try {
380
- setResults(await searchPlugins(namespace || undefined));
593
+ setResults(await searchPlugins(namespace || undefined, { prerelease }));
381
594
  }
382
595
  catch (err) {
383
596
  setResults(null);
@@ -460,7 +673,7 @@ function UninstalledPluginRow({ purge, busy, onRetry, onInstall }) {
460
673
  const failedSteps = purge.steps.filter((step) => !step.ok);
461
674
  return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: [_jsx("div", { className: "font-semibold", children: purgeName(purge) }), _jsx("div", { className: "text-xs text-text-muted", children: purge.name })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} hidden lg:table-cell` }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-3 basis-full lg:basis-auto`, children: _jsxs("div", { className: "flex flex-col items-start gap-1 text-xs max-w-sm", children: [_jsx("span", { className: `${BADGE_CLASS} bg-surface-alt text-text-muted`, children: "Uninstalled" }), purge.state === "pending" && (_jsxs(_Fragment, { children: [_jsx("span", { children: "Uninstalled - data will be deleted after servers restart" }), !!purge.serversRunning && (_jsxs("span", { className: "text-text-muted", children: [purge.serversRunning, " of ", purge.serversTotal, " ", purge.serversTotal === 1 ? "server" : "servers", " still running it"] }))] })), purge.state === "running" && _jsx("span", { children: "Uninstalled - deleting its data now" }), purge.state === "done" && _jsxs("span", { className: "text-success font-medium", children: ["Data deleted", purge.completedAt ? ` ${formatDate(purge.completedAt)}` : ""] }), purge.state === "failed" && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "text-danger break-words", children: ["Data deletion failed: ", purgeFailure(purge)] }), failedSteps.length > 0 && (_jsx("ul", { className: "list-disc pl-4 text-text-muted break-words", children: failedSteps.map((step) => (_jsxs("li", { children: [step.step, ": ", step.error ?? "failed"] }, step.step))) }))] }))] }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-1 pb-3`, children: _jsxs("div", { className: "flex gap-2 lg:justify-end", children: [purge.state === "failed" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Retry deleting the data of ${purgeName(purge)}`, disabled: busy, loading: busy, onClick: onRetry, children: "Retry" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Install ${purgeName(purge)}`, disabled: busy || purge.state === "running", onClick: onInstall, children: "Install" })] }) })] }));
462
675
  }
463
- function AddPluginModal({ open, onClose, onAdd, }) {
676
+ function AddPluginModal({ open, prerelease, onClose, onAdd, }) {
464
677
  const [name, setName] = useState("");
465
678
  const [lookup, setLookup] = useState(null);
466
679
  const [selectedVersion, setSelectedVersion] = useState("");
@@ -494,7 +707,7 @@ function AddPluginModal({ open, onClose, onAdd, }) {
494
707
  setLooking(true);
495
708
  setError(null);
496
709
  try {
497
- const found = await lookupPluginPackage(packageName, packageVersion);
710
+ const found = await lookupPluginPackage(packageName, packageVersion, { prerelease });
498
711
  if (token === lookupToken.current) {
499
712
  setLookup(found);
500
713
  setSelectedVersion(found.selected.version);
@@ -550,20 +763,20 @@ function AddPluginModal({ open, onClose, onAdd, }) {
550
763
  setLookup(null);
551
764
  } })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: looking && !lookup, disabled: busy || looking, children: "Find" })] }), lookup && (_jsxs("div", { className: "flex flex-col gap-3", children: [typeof manifest === "string" ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-sm font-semibold", children: lookup.package.name }), _jsx(Alert, { children: manifest })] })) : (_jsxs("div", { className: "text-sm", children: [_jsx("div", { className: "font-semibold", children: manifest?.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: lookup.package.name }), manifest?.description && _jsx("div", { className: "text-text-muted", children: manifest.description })] })), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selectedVersion, onChange: (e) => chooseVersion(e.target.value), children: lookup.package.versions.map((version) => (_jsxs("option", { value: version, children: [version, version === lookup.package.latest ? " (latest)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy || looking, disabled: busy || looking || typeof manifest === "string", onClick: () => void add(), children: "Add plugin" })] })] }))] }));
552
765
  }
553
- function ChangeVersionModal({ plugin, onClose, onSave, }) {
766
+ function ChangeVersionModal({ plugin, prerelease, onClose, onSave, }) {
554
767
  const [versions, setVersions] = useState(null);
555
768
  const [latest, setLatest] = useState();
556
769
  const [selected, setSelected] = useState(plugin.packageVersion);
557
770
  const [error, setError] = useState(null);
558
771
  const [busy, setBusy] = useState(false);
559
772
  useEffect(() => {
560
- lookupPluginPackage(plugin.name)
773
+ lookupPluginPackage(plugin.name, undefined, { prerelease })
561
774
  .then((found) => {
562
775
  setVersions(found.package.versions);
563
776
  setLatest(found.package.latest);
564
777
  })
565
778
  .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
566
- }, [plugin.name]);
779
+ }, [plugin.name, prerelease]);
567
780
  async function save() {
568
781
  setBusy(true);
569
782
  setError(null);
@@ -732,6 +945,25 @@ function SettingField({ definition, value, configured, onChange, }) {
732
945
  }
733
946
  return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: definition.label }), definition.type === "select" ? (_jsxs("select", { "aria-label": definition.label, className: INPUT_CLASS, value: String(value), onChange: (e) => onChange(e.target.value), children: [!definition.required && _jsx("option", { value: "", children: "Default" }), (definition.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })) : (_jsx("input", { "aria-label": definition.label, className: INPUT_CLASS, type: definition.type === "number" ? "number" : "text", min: definition.min, max: definition.max, required: definition.required && !configured, placeholder: placeholder, value: String(value), onChange: (e) => onChange(e.target.value) })), help] }));
734
947
  }
948
+ /** The word typed to confirm deleting the data of several plugins at once, in place of a plugin's name. */
949
+ const BULK_DELETE_WORD = "delete";
950
+ /** Asks for confirmation before several plugins are uninstalled at once, listing them, with the choice to delete the
951
+ * data they stored too. Deleting data is permanent, so it also takes `BULK_DELETE_WORD` typed out. */
952
+ function BulkUninstallModal({ plugins, onClose, onConfirm }) {
953
+ const [purgeData, setPurgeData] = useState(false);
954
+ const [typed, setTyped] = useState("");
955
+ const helpId = useId();
956
+ const single = plugins.length === 1;
957
+ const confirmed = !purgeData || typed.trim().toLowerCase() === BULK_DELETE_WORD;
958
+ return (_jsx(Modal, { open: true, onClose: onClose, title: `Uninstall ${pluralPlugins(plugins.length)}?`, children: _jsxs("form", { onSubmit: (event) => {
959
+ event.preventDefault();
960
+ if (confirmed) {
961
+ onConfirm(purgeData);
962
+ }
963
+ }, children: [_jsxs("p", { className: "text-sm mb-2", children: ["The servers stop running ", single ? "this plugin" : "these plugins", " after they restart:"] }), _jsx("ul", { className: "text-sm list-disc pl-5 mb-4", children: plugins.map((plugin) => (_jsx("li", { children: plugin.manifest.displayName }, plugin.uid))) }), _jsx("p", { className: "text-sm mb-4", children: purgeData
964
+ ? `Once every server has stopped running ${single ? "it" : "them"}, all the data ${single ? "it" : "they"} stored is deleted.`
965
+ : `Data ${single ? "it" : "they"} stored stays in the database, and adding ${single ? "the plugin" : "a plugin"} again brings it back.` }), _jsxs("div", { className: "mb-4", children: [_jsxs("label", { className: "flex items-start gap-2 text-sm font-semibold cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 h-4 w-4 shrink-0 accent-danger", checked: purgeData, "aria-describedby": helpId, onChange: (e) => setPurgeData(e.target.checked) }), _jsxs("span", { children: ["Also delete all data ", single ? "this plugin" : "these plugins", " stored"] })] }), _jsxs("div", { id: helpId, className: "mt-2 pl-6 text-xs text-text-muted", children: [_jsx("p", { children: "What is deleted, for each plugin:" }), _jsxs("ul", { className: "list-disc pl-5 mt-1 space-y-0.5", children: [_jsx("li", { children: "the database collections and tables its features use, with everything in them" }), _jsx("li", { children: "its saved settings" }), _jsx("li", { children: "its downloaded package and cached pages on the servers" }), _jsx("li", { children: "whatever else it cleans up itself, such as files it stored or data kept outside the database" })] }), _jsx("p", { className: "mt-2 text-danger font-semibold", children: "This can't be undone." })] })] }), purgeData && (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-xs text-text-muted mb-3", children: "Nothing is deleted while a server is still running a plugin. Adding a plugin again before its data is deleted cancels the deletion." }), _jsxs("label", { className: "block text-sm mb-4", children: [_jsxs("span", { children: ["Type ", _jsx("strong", { children: BULK_DELETE_WORD }), " to confirm"] }), _jsx("input", { type: "text", className: `${INPUT_CLASS} mt-1.5`, value: typed, autoComplete: "off", spellCheck: false, onChange: (e) => setTyped(e.target.value) })] })] })), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", className: purgeData ? DANGER_BUTTON_CLASS : "!w-auto", disabled: !confirmed, children: purgeData ? `Uninstall ${pluralPlugins(plugins.length)} and delete data` : `Uninstall ${pluralPlugins(plugins.length)}` })] })] }) }));
966
+ }
735
967
  function RemoveModal({ plugin, onClose, onRemoved }) {
736
968
  const [error, setError] = useState(null);
737
969
  const [busy, setBusy] = useState(false);
@@ -0,0 +1,2 @@
1
+ export declare function getAllowPrerelease(): boolean;
2
+ export declare function setAllowPrerelease(allow: boolean): void;
@@ -0,0 +1,35 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Whether the Plugins page offers prerelease versions (`1.0.0-beta.2`) of plugins - for an update, in the version lists
7
+ * and in a plugin search. Stored in `localStorage`, so it is a preference of this browser, not a setting the server
8
+ * keeps: it only decides what the administrator is shown, since an administrator can already install any published
9
+ * version by name. Off unless it was turned on.
10
+ *
11
+ * A read that finds nothing, or a `localStorage` that throws (private-browsing/storage-blocked contexts), is off, and a
12
+ * write that fails is swallowed, so a blocked store only means the choice doesn't survive a reload.
13
+ */
14
+ const STORAGE_KEY = "rapidmx:plugins-allow-prerelease";
15
+ export function getAllowPrerelease() {
16
+ try {
17
+ return localStorage.getItem(STORAGE_KEY) === "true";
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ export function setAllowPrerelease(allow) {
24
+ try {
25
+ if (allow) {
26
+ localStorage.setItem(STORAGE_KEY, "true");
27
+ }
28
+ else {
29
+ localStorage.removeItem(STORAGE_KEY);
30
+ }
31
+ }
32
+ catch {
33
+ // Not persisted; the choice still applies until the page is reloaded.
34
+ }
35
+ }
@@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
8
8
  import { HiOutlineClipboardDocumentList, HiOutlineFolderOpen } from "react-icons/hi2";
9
9
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
10
  import { listMatters } from "@rapidmx/react-shared/admin/mattersApi.js";
11
- import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
11
+ import { useSessionRefresh } from "@rapidmx/react-shared/auth/session.js";
12
12
  import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
13
13
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
14
  import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
@@ -41,7 +41,7 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
41
41
  const [status, setStatus] = useState("checking");
42
42
  const [error, setError] = useState(null);
43
43
  const [iconSrc, setIconSrc] = useState(DEFAULT_ICON_SRC);
44
- useRedirectIfUnauthenticated(userUid, authServerUrl);
44
+ useSessionRefresh(userUid, authServerUrl);
45
45
  // Only the icon is taken from branding here. The escrow console deliberately skips `useBranding()`, which
46
46
  // would inject the custom stylesheet, and never renders the admin-configured header/footer HTML - this
47
47
  // console handles escrow key material, so no admin-supplied markup or CSS runs on it.
@@ -7,7 +7,7 @@ import "../../styles/app.css";
7
7
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
8
8
  import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlinePuzzlePiece, HiOutlineUsers, } from "react-icons/hi2";
9
9
  import { useRouter } from "@rapidrest/react/client";
10
- import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
10
+ import { useSessionRefresh } from "@rapidmx/react-shared/auth/session.js";
11
11
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
12
12
  import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
13
13
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
@@ -118,7 +118,9 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
118
118
  useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
119
119
  // A meeting reminder pops up on whichever page they are - see the hook.
120
120
  useCalendarReminders({ userUid, enabled: inFrame });
121
- useRedirectIfUnauthenticated(userUid, authServerUrl);
121
+ // A refused refresh means the sign-in has ended: open compose windows save what they hold (the access token still works for a while) and the
122
+ // browser goes to sign-in. Not while viewing as another user - the refresh cookie is the admin's own, and refreshing would end the impersonation.
123
+ useSessionRefresh(userUid, authServerUrl, { paused: impersonating, beforeRedirect: () => flushComposeDrafts(LOGOUT_TIMEOUT_MS) });
122
124
  // Any request this app makes that the server answers with a 401 - the session ended - raises one "Your session expired" pop-up with a
123
125
  // Sign in action (see `notifySessionExpired()`), whichever request noticed first, a background refresh included.
124
126
  useEffect(() => {