@rapidmx/web-client 0.19.0 → 0.20.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 (131) hide show
  1. package/README.md +381 -381
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -482
  4. package/apps/admin/domains/[uid].tsx +166 -166
  5. package/apps/admin/escrow-scopes/[uid].tsx +350 -350
  6. package/apps/admin/index.tsx +129 -129
  7. package/apps/admin/mailboxes/[uid].tsx +271 -271
  8. package/apps/admin/mailboxes/new/index.tsx +30 -30
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
  16. package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
  17. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
  18. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
  19. package/apps/shared/components/admin/layout/AdminShell.tsx +384 -384
  20. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -238
  21. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  22. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -194
  23. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  25. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +2093 -1620
  27. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  28. package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -446
  31. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  32. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  33. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  34. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -103
  40. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  41. package/apps/shared/components/layout/AppShell.tsx +461 -461
  42. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  44. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  45. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  46. package/apps/shared/components/layout/UserMenu.tsx +439 -439
  47. package/apps/shared/components/mail/ConversationList.tsx +332 -323
  48. package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -613
  49. package/apps/shared/components/mail/MailSelectionBar.tsx +240 -240
  50. package/apps/shared/components/mail/MenuButton.tsx +404 -404
  51. package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1757
  52. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  53. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  54. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1681
  55. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  57. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  58. package/apps/shared/components/mail/listPreferences.ts +3 -3
  59. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -258
  60. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -79
  61. package/apps/shared/components/mail/reading/messageExport.ts +59 -59
  62. package/apps/shared/components/mail/reading/printMessage.ts +99 -99
  63. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -443
  64. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  65. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  66. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  67. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  68. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  69. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  70. package/apps/shared/keyboard/dispatch.ts +124 -124
  71. package/apps/shared/keyboard/format.ts +89 -89
  72. package/apps/shared/keyboard/keymap.ts +114 -114
  73. package/apps/shared/keyboard/registry.ts +65 -65
  74. package/apps/shared/keyboard/targets.ts +79 -79
  75. package/apps/shared/mail/folderOfType.ts +49 -49
  76. package/apps/shared/mail/folderTree.ts +143 -143
  77. package/apps/shared/mail/listAllPages.ts +39 -39
  78. package/apps/shared/mail/newMailNotifications.ts +171 -171
  79. package/apps/shared/mail/outbox/sendJob.ts +445 -445
  80. package/apps/shared/mail/outbox/sendOutcomes.ts +155 -155
  81. package/apps/shared/mail/reportNotices.ts +66 -66
  82. package/apps/shared/mail/senderBlocking.ts +141 -141
  83. package/apps/shared/mail/useMailConnection.ts +205 -205
  84. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  85. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -60
  86. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  87. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  88. package/apps/shared/notifications/store.ts +560 -560
  89. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  90. package/apps/shared/search/localIndexBuilder.ts +481 -481
  91. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  92. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  93. package/apps/shared/signing/enrollmentView.ts +251 -251
  94. package/apps/shared/signing/useNow.ts +19 -19
  95. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  96. package/apps/shared/styles/app.css +396 -396
  97. package/apps/www/calendar/index.tsx +581 -581
  98. package/apps/www/contacts/[uid].tsx +112 -112
  99. package/apps/www/index.tsx +3012 -2962
  100. package/apps/www/messages/[uid].tsx +139 -139
  101. package/apps/www/settings/auto-reply/index.tsx +136 -136
  102. package/apps/www/settings/blocked-senders/index.tsx +303 -303
  103. package/apps/www/settings/encryption/index.tsx +1290 -1290
  104. package/apps/www/settings/filters/[uid].tsx +179 -179
  105. package/apps/www/settings/filters/index.tsx +105 -105
  106. package/apps/www/settings/filters/new/index.tsx +165 -165
  107. package/apps/www/settings/labels/index.tsx +207 -207
  108. package/apps/www/settings/privacy/index.tsx +495 -495
  109. package/apps/www/settings/profile/index.tsx +251 -251
  110. package/apps/www/settings/read-receipts/index.tsx +150 -150
  111. package/apps/www/settings/sharing/index.tsx +259 -259
  112. package/apps/www/settings/signatures/[uid].tsx +175 -175
  113. package/apps/www/settings/signatures/index.tsx +91 -91
  114. package/apps/www/settings/signatures/new/index.tsx +138 -138
  115. package/apps/www/tasks/index.tsx +654 -654
  116. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
  117. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
  118. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
  119. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
  120. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
  121. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  122. package/dist/apps/shared/components/admin/settings/PluginsManager.js +267 -35
  123. package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
  124. package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
  125. package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
  126. package/dist/apps/shared/components/mail/ConversationList.js +9 -5
  127. package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/reading/printMessage.js +11 -11
  129. package/dist/apps/shared/styles/app.css +396 -396
  130. package/dist/apps/www/index.js +51 -10
  131. 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
+ }
@@ -1,9 +1,16 @@
1
1
  import React from "react";
2
2
  import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
3
  import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
4
+ /** A conversation row of the list. `mailboxUid` is set only by a view that merges several mailboxes ("All Mailboxes"), whose rows are
5
+ * each from a different one; a list of one mailbox's own leaves it out and every row is that mailbox's. */
6
+ export type ListedConversation = ConversationSummary & {
7
+ mailboxUid?: string;
8
+ };
9
+ /** What tells a row apart from the others: the same conversation id in two mailboxes (a message sent to both) is two rows. */
10
+ export declare function conversationRowKey(conversation: ListedConversation): string;
4
11
  export interface ConversationListProps {
5
- conversations: ConversationSummary[];
6
- /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. */
12
+ conversations: ListedConversation[];
13
+ /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. A row that names its own mailbox uses that. */
7
14
  mailboxUid: string;
8
15
  /** The message currently open in the reading pane, so the row standing for it can be marked. */
9
16
  selectedUid: string | null;
@@ -12,7 +19,7 @@ export interface ConversationListProps {
12
19
  * stands for, or the conversation's own `latestMessageUid` for a parent row. The conversation goes
13
20
  * with the uid because the pane shows the whole thread, not just that message.
14
21
  */
15
- onOpenMessage: (conversation: ConversationSummary, uid: string) => void;
22
+ onOpenMessage: (conversation: ListedConversation, uid: string) => void;
16
23
  /** Newer copies of messages this list already fetched - the reading pane marks the message it opens as
17
24
  * read, which this list would otherwise keep showing as unread until the thread is collapsed and
18
25
  * expanded again. */