@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.
- package/README.md +1 -1
- package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
- package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
- package/apps/shared/components/admin/layout/AdminShell.tsx +2 -2
- package/apps/shared/components/admin/settings/PluginsManager.tsx +508 -35
- package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +2 -2
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/mail/ConversationList.tsx +18 -9
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +24 -0
- package/apps/shared/components/mail/listPreferences.ts +3 -3
- package/apps/www/index.tsx +62 -12
- package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
- package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
- package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
- package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +267 -35
- package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
- package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +2 -2
- package/dist/apps/shared/components/layout/AppShell.js +4 -2
- package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
- package/dist/apps/shared/components/mail/ConversationList.js +9 -5
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +20 -1
- package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
- package/dist/apps/www/index.js +51 -10
- 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) =>
|
|
114
|
-
|
|
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, () =>
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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) })),
|
|
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
|
-
|
|
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,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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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(() => {
|