@rapidmx/web-client 0.18.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 -1545
  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 +328 -48
  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
@@ -1,1545 +1,2093 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useCallback, useEffect, useId, useRef, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- addPlugin,
9
- expectedPlanOf,
10
- getPluginStatus,
11
- getPluginUpdates,
12
- listPluginNamespaces,
13
- listPlugins,
14
- lookupPluginPackage,
15
- planPluginChange,
16
- Plugin,
17
- PluginChangePlan,
18
- PluginInstanceStatus,
19
- PluginNamespace,
20
- PluginPurgeInfo,
21
- PluginPurgeState,
22
- PluginRegistryLookup,
23
- PluginSearchResult,
24
- PluginSettingDefinition,
25
- PluginSettingValue,
26
- PluginStatus,
27
- PluginUpdateInfo,
28
- removePlugin,
29
- RemovePluginResult,
30
- retryPluginPurge,
31
- searchPlugins,
32
- updatePlugin,
33
- } from "@rapidmx/react-shared/admin/pluginsApi.js";
34
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
35
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
36
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
37
- import { notify } from "../../../notifications/store.js";
38
- import { isElevationRequired } from "../elevation.js";
39
-
40
- const INPUT_CLASS =
41
- "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
42
-
43
- /** How often status is refreshed while server copies are still applying a change. */
44
- const PENDING_POLL_MS = 5000;
45
-
46
- /** How long status keeps being polled after a saved change, since servers may not have noticed it yet. */
47
- const AFTER_CHANGE_POLL_MS = 2 * 60 * 1000;
48
-
49
- /** The longest wait between retries while no status has ever been read. */
50
- const MAX_STATUS_RETRY_MS = 60 * 1000;
51
-
52
- function errorMessage(err: unknown, fallback: string): string {
53
- return err instanceof ApiRequestError ? err.message : fallback;
54
- }
55
-
56
- /** Runs `action`, resolving why it failed, or `null`. */
57
- async function attempt(action: () => Promise<void>, failure: string): Promise<string | null> {
58
- try {
59
- await action();
60
- return null;
61
- } catch (err) {
62
- return errorMessage(err, failure);
63
- }
64
- }
65
-
66
- /** The classes of a confirm button that deletes data, the same as the app's other destructive buttons. */
67
- const DANGER_BUTTON_CLASS = "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger";
68
-
69
- /** Shown when the server wants the administrator to have confirmed their identity recently (`api-104`). */
70
- const ELEVATION_MESSAGE = "Deleting data needs you to have recently confirmed your identity. Reload this page, or sign in again, then try once more.";
71
-
72
- /** What an administrator sees an uninstalled plugin's data deletion as, without the display name of a plugin that's gone. */
73
- const purgeName = (purge: PluginPurgeInfo): string => purge.displayName ?? purge.name;
74
-
75
- /** Why a data deletion failed: what the server recorded, else the steps that failed. */
76
- function purgeFailure(purge: PluginPurgeInfo): string {
77
- return purge.error ?? (purge.steps.filter((step) => !step.ok).map((step) => step.error ?? step.step).join("; ") || "The reason wasn't recorded.");
78
- }
79
-
80
- /** A day, in the administrator's own format. */
81
- const formatDate = (iso: string): string => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
82
-
83
- /** A change waiting for the administrator to confirm the other plugins it also installs or enables. */
84
- interface PendingChange {
85
- displayName: string;
86
- plan: PluginChangePlan;
87
- apply: (plan: PluginChangePlan) => Promise<void>;
88
- failure: string;
89
- /** The installed plugin being changed, which stays busy while the change waits for confirmation. */
90
- uid?: string;
91
- }
92
-
93
- /** Classes for a plugin table's header and body cells. */
94
- const TH_CLASS = "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border";
95
- const TD_CLASS = "py-3 px-2.5 border-b border-border align-top";
96
-
97
- /**
98
- * The installed plugins table stacks each row on narrow screens - name and description, then version and status side
99
- * by side, then the actions - so the actions aren't scrolled off to the side. The explicit roles keep it a table for
100
- * assistive technology while it isn't displayed as one.
101
- */
102
- const INSTALLED_ROW_CLASS = "flex flex-wrap border-b border-border lg:table-row lg:border-b-0";
103
- const INSTALLED_TD_CLASS = "block px-2.5 align-top lg:table-cell lg:pt-3 lg:pb-3 lg:border-b lg:border-border";
104
-
105
- /** The small pill badge admin tables use for a state. */
106
- const BADGE_CLASS = "inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill";
107
-
108
- export interface PluginsManagerProps {
109
- /**
110
- * Set when shown inside another page that already has its own heading and introduction (the setup wizard): the
111
- * page heading and introduction are left out, and the section headings sit one level lower.
112
- */
113
- embedded?: boolean;
114
- }
115
-
116
- /** Installed plugins with their rollout status, and every plugin action - shared by the Plugins page and the
117
- * setup wizard. */
118
- export default function PluginsManager({ embedded = false }: PluginsManagerProps = {}) {
119
- const [plugins, setPlugins] = useState<Plugin[]>([]);
120
- const [status, setStatus] = useState<PluginStatus | null>(null);
121
- /** Set when the last status refresh failed, so what's shown may be out of date. */
122
- const [statusStale, setStatusStale] = useState(false);
123
- /** When polling started by a saved change stops (epoch ms), or 0 when there's none. */
124
- const [pollUntil, setPollUntil] = useState(0);
125
- const [updates, setUpdates] = useState<Map<string, PluginUpdateInfo>>(new Map());
126
- const [loading, setLoading] = useState(true);
127
- const [error, setError] = useState<string | null>(null);
128
- /** The installed plugin whose enable just failed, offered a retry next to `error`. */
129
- const [retryEnableUid, setRetryEnableUid] = useState<string | null>(null);
130
- const [adding, setAdding] = useState(false);
131
- const [upgrading, setUpgrading] = useState<Plugin | null>(null);
132
- const [configuring, setConfiguring] = useState<Plugin | null>(null);
133
- const [removing, setRemoving] = useState<Plugin | null>(null);
134
- const [confirming, setConfirming] = useState<PendingChange | null>(null);
135
- /** Installed plugins with an action under way. */
136
- const [busyUids, setBusyUids] = useState<ReadonlySet<string>>(new Set());
137
- /** Set when re-reading the list after a change failed, so rows may not show what that change also did. */
138
- const [listStale, setListStale] = useState(false);
139
- /** How many times status has been retried while none has ever been read. */
140
- const [statusRetries, setStatusRetries] = useState(0);
141
- const statusRequest = useRef<Promise<void> | null>(null);
142
- /** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
143
- const seenPurges = useRef<Map<string, PluginPurgeState>>(new Map());
144
-
145
- const setBusy = (uid: string, busy: boolean) =>
146
- setBusyUids((prev) => {
147
- const next = new Set(prev);
148
- if (busy) {
149
- next.add(uid);
150
- } else {
151
- next.delete(uid);
152
- }
153
- return next;
154
- });
155
-
156
- const refreshStatus = useCallback(() => {
157
- // One read at a time: a poll that comes due while a slow read is still running shares it.
158
- statusRequest.current ??= getPluginStatus()
159
- .then((next) => {
160
- setStatus(next);
161
- setStatusStale(false);
162
- })
163
- // Status is advisory - a failure to read it shouldn't hide the plugin list, or lose the last status read.
164
- .catch(() => setStatusStale(true))
165
- .finally(() => {
166
- statusRequest.current = null;
167
- });
168
- return statusRequest.current;
169
- }, []);
170
-
171
- const refreshUpdates = useCallback(() => {
172
- // Also advisory: a registry that can't be reached just means no update badges.
173
- return getPluginUpdates()
174
- .then((list) => setUpdates(new Map(list.map((info) => [info.uid, info]))))
175
- .catch(() => setUpdates(new Map()));
176
- }, []);
177
-
178
- /** Re-reads the list, for changes that also installed or enabled other plugins. */
179
- const reload = useCallback(() => {
180
- return listPlugins()
181
- .then((list) => {
182
- setPlugins(list);
183
- setListStale(false);
184
- })
185
- .catch(() => setListStale(true));
186
- }, []);
187
-
188
- useEffect(() => {
189
- Promise.all([listPlugins(), refreshStatus()])
190
- .then(([list]) => {
191
- setPlugins(list);
192
- void refreshUpdates();
193
- })
194
- .catch((err) => setError(errorMessage(err, "Could not load plugins.")))
195
- .finally(() => setLoading(false));
196
- }, [refreshStatus, refreshUpdates]);
197
-
198
- const pending: boolean = !!status && status.instances.some((instance) => instance.hash !== status.hash);
199
- // A plugin's data deletion in the status that isn't over yet, for a plugin that's uninstalled (one added again is hidden).
200
- const installedNames: Set<string> = new Set(plugins.map((plugin) => plugin.name));
201
- const purges: PluginPurgeInfo[] = (status?.purges ?? []).filter((purge) => !installedNames.has(purge.name));
202
- const purging: boolean = purges.some((purge) => purge.state === "pending" || purge.state === "running");
203
- useEffect(() => {
204
- if (!pending && !purging && pollUntil === 0) {
205
- return;
206
- }
207
- const timer = setInterval(() => void refreshStatus(), PENDING_POLL_MS);
208
- // Polling for a saved change ends after a while; a rollout that's under way keeps it going.
209
- const stop = pollUntil > 0 ? setTimeout(() => setPollUntil(0), Math.max(0, pollUntil - Date.now())) : undefined;
210
- return () => {
211
- clearInterval(timer);
212
- clearTimeout(stop);
213
- };
214
- }, [pending, purging, pollUntil, refreshStatus]);
215
-
216
- // A data deletion seen under way that has now ended is worth a notification: it happens after the servers restart,
217
- // long after the administrator asked for it.
218
- useEffect(() => {
219
- for (const purge of status?.purges ?? []) {
220
- const before = seenPurges.current.get(purge.uid);
221
- seenPurges.current.set(purge.uid, purge.state);
222
- if (before !== "pending" && before !== "running") {
223
- continue;
224
- }
225
- if (purge.state === "done") {
226
- notify({ kind: "success", title: `${purgeName(purge)}'s data was deleted`, dedupeKey: `plugin-purge-${purge.uid}-done` });
227
- } else if (purge.state === "failed") {
228
- notify({
229
- kind: "error",
230
- title: `Deleting ${purgeName(purge)}'s data failed`,
231
- message: purgeFailure(purge),
232
- dedupeKey: `plugin-purge-${purge.uid}-failed`,
233
- });
234
- }
235
- }
236
- }, [status]);
237
-
238
- // With no status read at all there's nothing pending to poll for, so keep retrying - less often each time.
239
- const neverRead: boolean = !status && statusStale;
240
- useEffect(() => {
241
- if (!neverRead) {
242
- return;
243
- }
244
- const timer = setTimeout(
245
- () => void refreshStatus().then(() => setStatusRetries((n) => n + 1)),
246
- Math.min(PENDING_POLL_MS * 2 ** statusRetries, MAX_STATUS_RETRY_MS),
247
- );
248
- return () => clearTimeout(timer);
249
- }, [neverRead, statusRetries, refreshStatus]);
250
-
251
- /** Re-reads status, and keeps doing so for a while, since saving starts a rollout servers pick up shortly. */
252
- function watchRollout() {
253
- setPollUntil(Date.now() + AFTER_CHANGE_POLL_MS);
254
- void refreshStatus();
255
- }
256
-
257
- /** Applies saved changes locally and re-reads status and updates, since saving starts a rollout. */
258
- function applied(...changed: Plugin[]) {
259
- setPlugins((prev) => {
260
- let next = prev;
261
- for (const updated of changed) {
262
- const exists = next.some((plugin) => plugin.uid === updated.uid);
263
- next = exists ? next.map((plugin) => (plugin.uid === updated.uid ? updated : plugin)) : [...next, updated];
264
- }
265
- return [...next].sort((a, b) => a.name.localeCompare(b.name));
266
- });
267
- watchRollout();
268
- void refreshUpdates();
269
- }
270
-
271
- /** Runs an action on a plugin's row (an installed plugin's, or an uninstalled one's), which is busy meanwhile, and
272
- * shows why it failed. */
273
- async function onRow(plugin: { uid: string }, action: () => Promise<string | null>) {
274
- setBusy(plugin.uid, true);
275
- setError(null);
276
- setRetryEnableUid(null);
277
- const problem = await action();
278
- if (problem) {
279
- setError(problem);
280
- }
281
- // A change waiting for confirmation keeps the row busy by itself, until the confirmation closes.
282
- setBusy(plugin.uid, false);
283
- }
284
-
285
- function toggle(plugin: Plugin) {
286
- const displayName: string = plugin.manifest.displayName;
287
- if (plugin.enabled) {
288
- return onRow(plugin, () =>
289
- attempt(async () => applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: false })), `Could not disable ${displayName}.`),
290
- );
291
- }
292
- // Enabling a plugin also installs or enables the plugins it requires, so it's previewed like any other change.
293
- // The installed version is planned from its stored manifest, so this doesn't need the registry. A preview that
294
- // fails is never skipped - enabling without one could install or enable other plugins the administrator never
295
- // saw - so the failure is shown with a retry instead.
296
- return onRow(plugin, async () => {
297
- const problem = await planned(
298
- plugin.name,
299
- plugin.packageVersion,
300
- displayName,
301
- async (plan) => {
302
- applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: true, expectedPlan: expectedPlanOf(plan) }));
303
- if (plan.install.length > 0 || plan.enable.length > 0) {
304
- void reload();
305
- }
306
- },
307
- `Could not enable ${displayName}.`,
308
- { uid: plugin.uid },
309
- );
310
- if (problem) {
311
- setRetryEnableUid(plugin.uid);
312
- }
313
- return problem;
314
- });
315
- }
316
-
317
- /**
318
- * Checks what installing or changing a plugin also takes before doing it. Resolves why it can't be done, or `null`
319
- * once it's done - or, when it also installs or enables other plugins, once they're shown for confirmation.
320
- */
321
- async function planned(
322
- name: string,
323
- packageVersion: string,
324
- displayName: string,
325
- apply: (plan: PluginChangePlan) => Promise<void>,
326
- failure: string,
327
- options: { uid?: string } = {},
328
- ): Promise<string | null> {
329
- let plan: PluginChangePlan;
330
- try {
331
- plan = await planPluginChange(name, packageVersion);
332
- } catch (err) {
333
- return errorMessage(err, failure);
334
- }
335
- if (plan.conflicts.length > 0) {
336
- return `${displayName} ${plan.plugin.version} can't be installed. ${plan.conflicts.join(" ")}`;
337
- }
338
- if (plan.install.length > 0 || plan.enable.length > 0) {
339
- setConfirming({ displayName, plan, apply, failure, uid: options.uid });
340
- return null;
341
- }
342
- return attempt(() => apply(plan), failure);
343
- }
344
-
345
- function install(name: string, packageVersion: string, displayName: string) {
346
- return planned(
347
- name,
348
- packageVersion,
349
- displayName,
350
- async (plan) => {
351
- const result = await addPlugin(name, packageVersion, expectedPlanOf(plan));
352
- applied(...result.dependencies, result.plugin);
353
- // Adding a plugin cancels the deletion of its data that was waiting for the servers to stop running it.
354
- for (const warning of result.warnings ?? []) {
355
- notify({ kind: "warning", title: "Data deletion cancelled", message: warning });
356
- }
357
- },
358
- `Could not install ${displayName}.`,
359
- );
360
- }
361
-
362
- function changeVersion(plugin: Plugin, packageVersion: string) {
363
- const failure = `Could not upgrade ${plugin.manifest.displayName}.`;
364
- if (!plugin.enabled) {
365
- // A disabled plugin stays disabled, so nothing it requires is installed or enabled - there's nothing to preview.
366
- return attempt(async () => applied(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion })), failure);
367
- }
368
- return planned(
369
- plugin.name,
370
- packageVersion,
371
- plugin.manifest.displayName,
372
- async (plan) => {
373
- applied(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion, expectedPlan: expectedPlanOf(plan) }));
374
- if (plan.install.length > 0 || plan.enable.length > 0) {
375
- void reload();
376
- }
377
- },
378
- failure,
379
- { uid: plugin.uid },
380
- );
381
- }
382
-
383
- function upgrade(plugin: Plugin, packageVersion: string) {
384
- return onRow(plugin, () => changeVersion(plugin, packageVersion));
385
- }
386
-
387
- /** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
388
- function reinstall(purge: PluginPurgeInfo) {
389
- const displayName: string = purgeName(purge);
390
- return onRow(purge, async () => {
391
- let latest: string;
392
- try {
393
- latest = (await planPluginChange(purge.name)).plugin.version;
394
- } catch (err) {
395
- return errorMessage(err, `Could not install ${displayName}.`);
396
- }
397
- return install(purge.name, latest, displayName);
398
- });
399
- }
400
-
401
- /** Runs the steps of a failed data deletion that failed, again. */
402
- async function retryPurge(purge: PluginPurgeInfo) {
403
- setBusy(purge.uid, true);
404
- setError(null);
405
- setRetryEnableUid(null);
406
- try {
407
- await retryPluginPurge(purge.uid);
408
- // The retry starts on a server shortly; keep reading status until it ends.
409
- setPollUntil(Date.now() + AFTER_CHANGE_POLL_MS);
410
- await refreshStatus();
411
- } catch (err) {
412
- setError(isElevationRequired(err) ? ELEVATION_MESSAGE : errorMessage(err, `Could not retry deleting ${purgeName(purge)}'s data.`));
413
- }
414
- setBusy(purge.uid, false);
415
- }
416
-
417
- // Retried against the plugin as it's listed now (a newer version, or already enabled elsewhere, hides the retry).
418
- const retryEnable: Plugin | undefined = plugins.find((plugin) => plugin.uid === retryEnableUid && !plugin.enabled);
419
-
420
- const displayNameOf = (name: string): string => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
421
-
422
- const SectionHeading = embedded ? "h3" : "h2";
423
- const addButton = (
424
- <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => setAdding(true)}>
425
- Add by name
426
- </Button>
427
- );
428
-
429
- return (
430
- <>
431
- {embedded ? (
432
- <p className="text-sm text-text-muted mb-5 max-w-3xl">
433
- Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run
434
- with full access to the server, so only add ones you trust.
435
- </p>
436
- ) : (
437
- <>
438
- <div className="flex items-center justify-between gap-4 mb-2">
439
- <h1 className="text-xl font-bold uppercase tracking-wide">Plugins</h1>
440
- {addButton}
441
- </div>
442
- <p className="text-sm text-text-muted mb-6 max-w-3xl">
443
- Plugins add protocols and features to every server. Changes are applied by restarting the
444
- servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so
445
- only add ones you trust.
446
- </p>
447
- </>
448
- )}
449
-
450
- {error && (
451
- <Alert>
452
- {error}
453
- {retryEnable && (
454
- <>
455
- {" "}
456
- <Button
457
- type="button"
458
- variant="text"
459
- className="!w-auto !p-0"
460
- onClick={() => void toggle(retryEnable)}
461
- >
462
- Try again
463
- </Button>
464
- </>
465
- )}
466
- </Alert>
467
- )}
468
- <RolloutBanner status={status} />
469
- {statusStale && status && (
470
- <p className="mb-4 text-xs text-text-muted">Couldn&apos;t refresh server status. Showing the last status reported.</p>
471
- )}
472
- {listStale && (
473
- <p className="mb-4 text-xs text-text-muted">
474
- Couldn&apos;t re-read the plugin list, so it may be out of date.{" "}
475
- <Button type="button" variant="text" className="!w-auto !p-0 !text-xs" onClick={() => void reload()}>
476
- Retry
477
- </Button>
478
- </p>
479
- )}
480
-
481
- <div className="flex items-center justify-between gap-4 mb-2">
482
- <SectionHeading className="text-sm font-bold uppercase tracking-wide">Installed plugins</SectionHeading>
483
- {embedded && addButton}
484
- </div>
485
- {loading ? (
486
- <p className="text-sm text-text-muted">Loading&hellip;</p>
487
- ) : plugins.length === 0 && purges.length === 0 ? (
488
- <p className="text-sm text-text-muted">No plugins installed.</p>
489
- ) : (
490
- <div className="overflow-x-auto">
491
- <table role="table" className="w-full text-sm border-collapse block lg:table">
492
- <thead role="rowgroup" className="hidden lg:table-header-group">
493
- <tr role="row">
494
- <th role="columnheader" className={TH_CLASS}>
495
- Plugin
496
- </th>
497
- <th role="columnheader" className={TH_CLASS}>
498
- Version
499
- </th>
500
- <th role="columnheader" className={TH_CLASS}>
501
- Status
502
- </th>
503
- <th role="columnheader" className={TH_CLASS}>
504
- <span className="sr-only">Actions</span>
505
- </th>
506
- </tr>
507
- </thead>
508
- <tbody role="rowgroup" className="block lg:table-row-group">
509
- {plugins.map((plugin) => {
510
- const update: PluginUpdateInfo | undefined = updates.get(plugin.uid);
511
- const latest: string | undefined = update?.updateAvailable ? update.latestVersion : undefined;
512
- const busy: boolean = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid;
513
- const requires: string[] = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
514
- const requiredBy: string[] = plugins
515
- .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
516
- .map((other) => other.manifest.displayName);
517
- return (
518
- <tr key={plugin.uid} role="row" className={INSTALLED_ROW_CLASS}>
519
- <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`}>
520
- <div className="font-semibold">{plugin.manifest.displayName}</div>
521
- <div className="text-xs text-text-muted">{plugin.name}</div>
522
- {plugin.manifest.description && (
523
- <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
524
- )}
525
- {requires.length > 0 && <div className="text-xs text-text-muted mt-1">Requires: {requires.join(", ")}</div>}
526
- {requiredBy.length > 0 && (
527
- <div className="text-xs text-text-muted mt-1">Required by: {requiredBy.join(", ")}</div>
528
- )}
529
- </td>
530
- <td role="cell" className={`${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`}>
531
- <div>{plugin.packageVersion}</div>
532
- {latest && <div className="mt-1 text-xs font-semibold text-primary-dark">Update available: {latest}</div>}
533
- </td>
534
- <td role="cell" className={`${INSTALLED_TD_CLASS} pt-1 pb-1`}>
535
- <PluginStatusCell plugin={plugin} status={status} />
536
- </td>
537
- <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`}>
538
- <div className="flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]">
539
- <div className="flex flex-wrap gap-2 lg:justify-end">
540
- {latest && (
541
- <Button
542
- type="button"
543
- variant="secondary"
544
- className="!w-auto"
545
- aria-label={`Upgrade ${plugin.manifest.displayName} to ${latest}`}
546
- disabled={busy}
547
- onClick={() => void upgrade(plugin, latest)}
548
- >
549
- Upgrade
550
- </Button>
551
- )}
552
- {(plugin.manifest.settings ?? []).length > 0 && (
553
- <Button variant="secondary" type="button" className="!w-auto" onClick={() => setConfiguring(plugin)}>
554
- Settings
555
- </Button>
556
- )}
557
- <Button
558
- variant={plugin.enabled ? "secondary" : "primary"}
559
- type="button"
560
- className="!w-auto min-w-[5.5rem]"
561
- aria-label={`${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`}
562
- disabled={busy}
563
- onClick={() => void toggle(plugin)}
564
- >
565
- {plugin.enabled ? "Disable" : "Enable"}
566
- </Button>
567
- </div>
568
- <div className="flex flex-wrap gap-x-4 lg:justify-end">
569
- <Button variant="text" type="button" onClick={() => setUpgrading(plugin)}>
570
- Change version
571
- </Button>
572
- <Button variant="text" type="button" onClick={() => setRemoving(plugin)}>
573
- Uninstall
574
- </Button>
575
- </div>
576
- </div>
577
- </td>
578
- </tr>
579
- );
580
- })}
581
- {purges.map((purge) => (
582
- <UninstalledPluginRow
583
- key={purge.uid}
584
- purge={purge}
585
- busy={busyUids.has(purge.uid)}
586
- onRetry={() => void retryPurge(purge)}
587
- onInstall={() => void reinstall(purge)}
588
- />
589
- ))}
590
- </tbody>
591
- </table>
592
- </div>
593
- )}
594
-
595
- <PluginBrowser
596
- headingLevel={SectionHeading}
597
- plugins={plugins}
598
- onInstall={(result) => install(result.name, result.version, result.name)}
599
- // The browser only offers an upgrade for a result it matched to one of these same `plugins`.
600
- onUpgrade={(uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid)!, packageVersion)}
601
- />
602
-
603
- <AddPluginModal
604
- open={adding}
605
- onClose={() => setAdding(false)}
606
- onAdd={async (name, packageVersion, displayName) => {
607
- const problem = await install(name, packageVersion, displayName);
608
- if (!problem) {
609
- setAdding(false);
610
- }
611
- return problem;
612
- }}
613
- />
614
- {upgrading && (
615
- <ChangeVersionModal
616
- plugin={upgrading}
617
- onClose={() => setUpgrading(null)}
618
- onSave={async (packageVersion) => {
619
- const problem = await changeVersion(upgrading, packageVersion);
620
- if (!problem) {
621
- setUpgrading(null);
622
- }
623
- return problem;
624
- }}
625
- />
626
- )}
627
- {confirming && (
628
- <ConfirmDependenciesModal
629
- change={confirming}
630
- displayNameOf={displayNameOf}
631
- onClose={() => setConfirming(null)}
632
- onDone={() => setConfirming(null)}
633
- />
634
- )}
635
- {configuring && (
636
- <SettingsModal
637
- plugin={configuring}
638
- onClose={() => setConfiguring(null)}
639
- onSaved={(plugin) => {
640
- setConfiguring(null);
641
- applied(plugin);
642
- }}
643
- />
644
- )}
645
- {removing && (
646
- <RemoveModal
647
- plugin={removing}
648
- onClose={() => setRemoving(null)}
649
- onRemoved={(result) => {
650
- const uid = removing.uid;
651
- const displayName = removing.manifest.displayName;
652
- setRemoving(null);
653
- setPlugins((prev) => prev.filter((plugin) => plugin.uid !== uid));
654
- if (result.purgeScheduled) {
655
- // Shown at once; the status polling that follows replaces it with what the servers report.
656
- const scheduled = result.purge;
657
- setStatus((prev) =>
658
- prev && scheduled ? { ...prev, purges: [...(prev.purges ?? []).filter((purge) => purge.uid !== scheduled.uid), scheduled] } : prev,
659
- );
660
- notify({
661
- kind: "info",
662
- title: `${displayName} uninstalled`,
663
- message: "Its data is deleted once every server has stopped running it. Adding the plugin again before then cancels the deletion.",
664
- });
665
- }
666
- watchRollout();
667
- }}
668
- />
669
- )}
670
- </>
671
- );
672
- }
673
-
674
- const ALL_NAMESPACES = "";
675
-
676
- /** Searches the configured namespaces' registries for plugin packages, with install and upgrade actions. */
677
- function PluginBrowser({
678
- headingLevel: Heading,
679
- plugins,
680
- onInstall,
681
- onUpgrade,
682
- }: {
683
- headingLevel: "h2" | "h3";
684
- plugins: Plugin[];
685
- /** Resolves why the plugin couldn't be installed, or `null`. */
686
- onInstall: (result: PluginSearchResult) => Promise<string | null>;
687
- onUpgrade: (uid: string, packageVersion: string) => Promise<void>;
688
- }) {
689
- const [namespaces, setNamespaces] = useState<PluginNamespace[]>([]);
690
- const [namespace, setNamespace] = useState(ALL_NAMESPACES);
691
- const [results, setResults] = useState<PluginSearchResult[] | null>(null);
692
- const [searching, setSearching] = useState(false);
693
- const [busyName, setBusyName] = useState<string | null>(null);
694
- const [error, setError] = useState<string | null>(null);
695
-
696
- useEffect(() => {
697
- listPluginNamespaces()
698
- .then(setNamespaces)
699
- .catch(() => setNamespaces([]));
700
- }, []);
701
-
702
- async function search(e: FormEvent) {
703
- e.preventDefault();
704
- setSearching(true);
705
- setError(null);
706
- try {
707
- setResults(await searchPlugins(namespace || undefined));
708
- } catch (err) {
709
- setResults(null);
710
- setError(errorMessage(err, "Could not search for plugins."));
711
- } finally {
712
- setSearching(false);
713
- }
714
- }
715
-
716
- // Keep results in step with installs and upgrades made anywhere on the page.
717
- const rows: PluginSearchResult[] = (results ?? []).map((result) => {
718
- const installed: Plugin | undefined = plugins.find((plugin) => plugin.name === result.name);
719
- return {
720
- ...result,
721
- installedUid: installed?.uid,
722
- installedVersion: installed?.packageVersion,
723
- updateAvailable: !!installed && installed.packageVersion !== result.version && result.updateAvailable,
724
- };
725
- });
726
-
727
- async function install(result: PluginSearchResult) {
728
- setBusyName(result.name);
729
- setError(null);
730
- setError(await onInstall(result));
731
- setBusyName(null);
732
- }
733
-
734
- async function upgrade(result: PluginSearchResult) {
735
- setBusyName(result.name);
736
- try {
737
- await onUpgrade(result.installedUid!, result.version);
738
- } finally {
739
- setBusyName(null);
740
- }
741
- }
742
-
743
- return (
744
- <section aria-labelledby="plugin-browser-title" className="mt-8">
745
- <Heading id="plugin-browser-title" className="text-sm font-bold uppercase tracking-wide mb-2">
746
- Find plugins
747
- </Heading>
748
- <form onSubmit={(e) => void search(e)} className="flex flex-wrap gap-2 items-end mb-3">
749
- <label className="flex flex-col gap-1.5 text-sm">
750
- <span className="font-semibold">Namespace</span>
751
- <select aria-label="Namespace" className={INPUT_CLASS} value={namespace} onChange={(e) => setNamespace(e.target.value)}>
752
- <option value={ALL_NAMESPACES}>All namespaces</option>
753
- {namespaces.map((ns) => (
754
- <option key={ns.name} value={ns.name}>
755
- {ns.name}
756
- </option>
757
- ))}
758
- </select>
759
- </label>
760
- <Button type="submit" className="!w-auto" loading={searching} disabled={searching}>
761
- Search
762
- </Button>
763
- </form>
764
- {error && <Alert>{error}</Alert>}
765
- {results !== null &&
766
- (rows.length === 0 ? (
767
- <p className="text-sm text-text-muted">No plugins found.</p>
768
- ) : (
769
- <div className="overflow-x-auto">
770
- <table className="w-full text-sm border-collapse">
771
- <thead>
772
- <tr>
773
- <th className={TH_CLASS}>Package</th>
774
- <th className={TH_CLASS}>Latest version</th>
775
- <th className={TH_CLASS}>Status</th>
776
- <th className={TH_CLASS}>
777
- <span className="sr-only">Actions</span>
778
- </th>
779
- </tr>
780
- </thead>
781
- <tbody>
782
- {rows.map((result) => (
783
- <tr key={result.name}>
784
- <td className={TD_CLASS}>
785
- <div className="font-semibold">{result.name}</div>
786
- {result.description && <div className="text-xs text-text-muted max-w-md">{result.description}</div>}
787
- </td>
788
- <td className={TD_CLASS}>{result.version}</td>
789
- <td className={`${TD_CLASS} text-text-muted`}>
790
- {result.updateAvailable
791
- ? `Installed ${result.installedVersion} - update available`
792
- : result.installedUid
793
- ? `Installed ${result.installedVersion}`
794
- : result.allowed
795
- ? "Not installed"
796
- : "Not allowed on this server"}
797
- </td>
798
- <td className={`${TD_CLASS} text-right`}>
799
- {result.updateAvailable ? (
800
- <Button
801
- type="button"
802
- variant="secondary"
803
- className="!w-auto"
804
- aria-label={`Upgrade ${result.name} to ${result.version}`}
805
- disabled={busyName === result.name}
806
- onClick={() => void upgrade(result)}
807
- >
808
- Upgrade
809
- </Button>
810
- ) : (
811
- !result.installedUid &&
812
- result.allowed && (
813
- <Button
814
- type="button"
815
- className="!w-auto"
816
- aria-label={`Install ${result.name}`}
817
- loading={busyName === result.name}
818
- disabled={busyName === result.name}
819
- onClick={() => void install(result)}
820
- >
821
- Install
822
- </Button>
823
- )
824
- )}
825
- </td>
826
- </tr>
827
- ))}
828
- </tbody>
829
- </table>
830
- </div>
831
- ))}
832
- </section>
833
- );
834
- }
835
-
836
- /** Name a server reports an error under when it isn't about any single plugin (e.g. the plugin set as a whole failed). */
837
- const SERVER_WIDE_ERROR_NAME = "*";
838
-
839
- /**
840
- * Shown while any server copy hasn't applied the saved plugin set yet, when one is in safe mode, or when one reports a
841
- * server-wide plugin error - those match no plugin row, so without this they'd never be shown.
842
- */
843
- function RolloutBanner({ status }: { status: PluginStatus | null }) {
844
- if (!status || status.instances.length === 0) {
845
- return null;
846
- }
847
- const behind = status.instances.filter((instance) => instance.hash !== status.hash).length;
848
- const safeMode = status.instances.filter((instance) => instance.safeMode);
849
- const serverWideErrors = status.instances.flatMap((instance) =>
850
- instance.errors
851
- .filter((entry) => entry.name === SERVER_WIDE_ERROR_NAME)
852
- .map((entry) => `${instance.instance}: ${entry.message}`),
853
- );
854
- return (
855
- <>
856
- {serverWideErrors.length > 0 && (
857
- <Alert>
858
- Plugins couldn&rsquo;t be loaded:
859
- <ul className="list-disc pl-5 mt-1">
860
- {serverWideErrors.map((message) => (
861
- <li key={message} className="break-words">
862
- {message}
863
- </li>
864
- ))}
865
- </ul>
866
- </Alert>
867
- )}
868
- {behind > 0 && (
869
- <p role="status" className="mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
870
- Applying changes: {status.instances.length - behind} of {status.instances.length}{" "}
871
- {status.instances.length === 1 ? "server" : "servers"} updated. Servers restart one at a time.
872
- </p>
873
- )}
874
- {safeMode.length > 0 && (
875
- <Alert>
876
- {safeMode.map((instance) => instance.instance).join(", ")} started without any plugins because recent
877
- starts failed. Fix or disable the failing plugin, and the server will pick up the change.
878
- </Alert>
879
- )}
880
- </>
881
- );
882
- }
883
-
884
- /** A plugin's enabled state and, for an enabled one, how many servers have loaded it and what went wrong. */
885
- function PluginStatusCell({ plugin, status }: { plugin: Plugin; status: PluginStatus | null }) {
886
- if (!plugin.enabled) {
887
- return <span className={`${BADGE_CLASS} bg-surface-alt text-text-muted`}>Disabled</span>;
888
- }
889
- return (
890
- <div className="flex flex-col items-start gap-1">
891
- <span className={`${BADGE_CLASS} bg-success text-white`}>Enabled</span>
892
- <PluginServers plugin={plugin} status={status} />
893
- </div>
894
- );
895
- }
896
-
897
- function PluginServers({ plugin, status }: { plugin: Plugin; status: PluginStatus | null }) {
898
- if (!status || status.instances.length === 0) {
899
- return <span className="text-xs text-text-muted">Server status unknown</span>;
900
- }
901
- const current: PluginInstanceStatus[] = status.instances.filter((instance) => instance.hash === status.hash);
902
- const loaded = current.filter((instance) =>
903
- instance.loaded.some((entry) => entry.name === plugin.name && entry.version === plugin.packageVersion),
904
- ).length;
905
- const errors = current.flatMap((instance) =>
906
- instance.errors.filter((entry) => entry.name === plugin.name).map((entry) => `${instance.instance}: ${entry.message}`),
907
- );
908
- return (
909
- <div className="text-xs">
910
- <span className={loaded === status.instances.length ? "text-success font-medium" : "text-text-muted"}>
911
- Loaded on {loaded} of {status.instances.length} {status.instances.length === 1 ? "server" : "servers"}
912
- </span>
913
- {errors.map((message) => (
914
- <div key={message} className="text-danger mt-1 break-words">
915
- {message}
916
- </div>
917
- ))}
918
- </div>
919
- );
920
- }
921
-
922
- /** The row of a plugin that was uninstalled but whose data deletion is still listed: what became of its data. */
923
- function UninstalledPluginRow({ purge, busy, onRetry, onInstall }: { purge: PluginPurgeInfo; busy: boolean; onRetry: () => void; onInstall: () => void }) {
924
- const failedSteps = purge.steps.filter((step) => !step.ok);
925
- return (
926
- <tr role="row" className={INSTALLED_ROW_CLASS}>
927
- <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`}>
928
- <div className="font-semibold">{purgeName(purge)}</div>
929
- <div className="text-xs text-text-muted">{purge.name}</div>
930
- </td>
931
- <td role="cell" className={`${INSTALLED_TD_CLASS} hidden lg:table-cell`} />
932
- <td role="cell" className={`${INSTALLED_TD_CLASS} pt-1 pb-3 basis-full lg:basis-auto`}>
933
- <div className="flex flex-col items-start gap-1 text-xs max-w-sm">
934
- <span className={`${BADGE_CLASS} bg-surface-alt text-text-muted`}>Uninstalled</span>
935
- {purge.state === "pending" && (
936
- <>
937
- <span>Uninstalled - data will be deleted after servers restart</span>
938
- {!!purge.serversRunning && (
939
- <span className="text-text-muted">
940
- {purge.serversRunning} of {purge.serversTotal} {purge.serversTotal === 1 ? "server" : "servers"} still running it
941
- </span>
942
- )}
943
- </>
944
- )}
945
- {purge.state === "running" && <span>Uninstalled - deleting its data now</span>}
946
- {purge.state === "done" && <span className="text-success font-medium">Data deleted{purge.completedAt ? ` ${formatDate(purge.completedAt)}` : ""}</span>}
947
- {purge.state === "failed" && (
948
- <>
949
- <span className="text-danger break-words">Data deletion failed: {purgeFailure(purge)}</span>
950
- {failedSteps.length > 0 && (
951
- <ul className="list-disc pl-4 text-text-muted break-words">
952
- {failedSteps.map((step) => (
953
- <li key={step.step}>
954
- {step.step}: {step.error ?? "failed"}
955
- </li>
956
- ))}
957
- </ul>
958
- )}
959
- </>
960
- )}
961
- </div>
962
- </td>
963
- <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-1 pb-3`}>
964
- <div className="flex gap-2 lg:justify-end">
965
- {purge.state === "failed" && (
966
- <Button
967
- type="button"
968
- variant="secondary"
969
- className="!w-auto"
970
- aria-label={`Retry deleting the data of ${purgeName(purge)}`}
971
- disabled={busy}
972
- loading={busy}
973
- onClick={onRetry}
974
- >
975
- Retry
976
- </Button>
977
- )}
978
- {/* A plugin can always be installed again. Adding it before its data is deleted cancels the deletion;
979
- while the deletion is running the server asks to wait until it has finished. */}
980
- <Button
981
- type="button"
982
- variant="secondary"
983
- className="!w-auto"
984
- aria-label={`Install ${purgeName(purge)}`}
985
- disabled={busy || purge.state === "running"}
986
- onClick={onInstall}
987
- >
988
- Install
989
- </Button>
990
- </div>
991
- </td>
992
- </tr>
993
- );
994
- }
995
-
996
- function AddPluginModal({
997
- open,
998
- onClose,
999
- onAdd,
1000
- }: {
1001
- open: boolean;
1002
- onClose: () => void;
1003
- /** Resolves why the plugin couldn't be added, or `null`. */
1004
- onAdd: (name: string, packageVersion: string, displayName: string) => Promise<string | null>;
1005
- }) {
1006
- const [name, setName] = useState("");
1007
- const [lookup, setLookup] = useState<PluginRegistryLookup | null>(null);
1008
- const [selectedVersion, setSelectedVersion] = useState("");
1009
- const [error, setError] = useState<string | null>(null);
1010
- const [busy, setBusy] = useState(false);
1011
- const [looking, setLooking] = useState(false);
1012
- /** Identifies the latest lookup, so a slower earlier response can't replace what it found. */
1013
- const lookupToken = useRef(0);
1014
- /** Identifies each time the dialog is opened, so an add that finishes after it was closed can't show its result. */
1015
- const openToken = useRef(0);
1016
-
1017
- /** Forgets any lookup still in flight. */
1018
- function invalidateLookup() {
1019
- lookupToken.current++;
1020
- setLooking(false);
1021
- }
1022
-
1023
- useEffect(() => {
1024
- if (!open) {
1025
- openToken.current++;
1026
- invalidateLookup();
1027
- setName("");
1028
- setLookup(null);
1029
- setSelectedVersion("");
1030
- setError(null);
1031
- setBusy(false);
1032
- }
1033
- }, [open]);
1034
-
1035
- /** Looks up `packageName` at `packageVersion` (default: latest). `previous` is what a version change started
1036
- * from - kept, with the failure in place of its manifest, when that version can't be read. */
1037
- async function runLookup(packageName: string, packageVersion?: string, previous?: PluginRegistryLookup) {
1038
- const token = ++lookupToken.current;
1039
- setLooking(true);
1040
- setError(null);
1041
- try {
1042
- const found = await lookupPluginPackage(packageName, packageVersion);
1043
- if (token === lookupToken.current) {
1044
- setLookup(found);
1045
- setSelectedVersion(found.selected.version);
1046
- }
1047
- } catch (err) {
1048
- if (token !== lookupToken.current) {
1049
- return;
1050
- }
1051
- const message = errorMessage(err, "Could not look that package up.");
1052
- if (previous && packageVersion) {
1053
- setLookup({ ...previous, selected: { ...previous.selected, version: packageVersion, manifest: message } });
1054
- } else {
1055
- setLookup(null);
1056
- setError(message);
1057
- }
1058
- } finally {
1059
- if (token === lookupToken.current) {
1060
- setLooking(false);
1061
- }
1062
- }
1063
- }
1064
-
1065
- function find(e: FormEvent) {
1066
- e.preventDefault();
1067
- if (!name.trim()) {
1068
- setError("Enter a package name.");
1069
- return;
1070
- }
1071
- void runLookup(name.trim());
1072
- }
1073
-
1074
- function chooseVersion(version: string) {
1075
- setSelectedVersion(version);
1076
- // The manifest shown, and whether it's a loadable plugin, depend on the version.
1077
- void runLookup(lookup!.package.name, version, lookup!);
1078
- }
1079
-
1080
- const manifest = lookup?.selected.manifest;
1081
-
1082
- async function add() {
1083
- const token: number = openToken.current;
1084
- setBusy(true);
1085
- setError(null);
1086
- const name: string = lookup!.package.name;
1087
- const problem = await onAdd(name, selectedVersion, typeof manifest === "object" ? manifest.displayName : name);
1088
- if (token === openToken.current) {
1089
- setError(problem);
1090
- setBusy(false);
1091
- }
1092
- }
1093
-
1094
- return (
1095
- <Modal open={open} onClose={onClose} title="Add plugin">
1096
- {error && <Alert>{error}</Alert>}
1097
- <form onSubmit={find} className="flex gap-2 items-end mb-4">
1098
- <label className="flex flex-col gap-1.5 text-sm flex-1">
1099
- <span className="font-semibold">Package name</span>
1100
- <input
1101
- aria-label="Package name"
1102
- className={INPUT_CLASS}
1103
- value={name}
1104
- placeholder="@rapidmx/activesync-plugin"
1105
- onChange={(e) => {
1106
- setName(e.target.value);
1107
- invalidateLookup();
1108
- setLookup(null);
1109
- }}
1110
- />
1111
- </label>
1112
- <Button type="submit" variant="secondary" className="!w-auto" loading={looking && !lookup} disabled={busy || looking}>
1113
- Find
1114
- </Button>
1115
- </form>
1116
- {lookup && (
1117
- <div className="flex flex-col gap-3">
1118
- {typeof manifest === "string" ? (
1119
- <>
1120
- <div className="text-sm font-semibold">{lookup.package.name}</div>
1121
- <Alert>{manifest}</Alert>
1122
- </>
1123
- ) : (
1124
- <div className="text-sm">
1125
- <div className="font-semibold">{manifest?.displayName}</div>
1126
- <div className="text-xs text-text-muted">{lookup.package.name}</div>
1127
- {manifest?.description && <div className="text-text-muted">{manifest.description}</div>}
1128
- </div>
1129
- )}
1130
- <label className="flex flex-col gap-1.5 text-sm">
1131
- <span className="font-semibold">Version</span>
1132
- <select aria-label="Version" className={INPUT_CLASS} value={selectedVersion} onChange={(e) => chooseVersion(e.target.value)}>
1133
- {lookup.package.versions.map((version) => (
1134
- <option key={version} value={version}>
1135
- {version}
1136
- {version === lookup.package.latest ? " (latest)" : ""}
1137
- </option>
1138
- ))}
1139
- </select>
1140
- </label>
1141
- <div className="flex gap-2 justify-end">
1142
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1143
- Cancel
1144
- </Button>
1145
- <Button
1146
- type="button"
1147
- className="!w-auto"
1148
- loading={busy || looking}
1149
- disabled={busy || looking || typeof manifest === "string"}
1150
- onClick={() => void add()}
1151
- >
1152
- Add plugin
1153
- </Button>
1154
- </div>
1155
- </div>
1156
- )}
1157
- </Modal>
1158
- );
1159
- }
1160
-
1161
- function ChangeVersionModal({
1162
- plugin,
1163
- onClose,
1164
- onSave,
1165
- }: {
1166
- plugin: Plugin;
1167
- onClose: () => void;
1168
- /** Resolves why the version couldn't be changed, or `null`. */
1169
- onSave: (packageVersion: string) => Promise<string | null>;
1170
- }) {
1171
- const [versions, setVersions] = useState<string[] | null>(null);
1172
- const [latest, setLatest] = useState<string | undefined>();
1173
- const [selected, setSelected] = useState(plugin.packageVersion);
1174
- const [error, setError] = useState<string | null>(null);
1175
- const [busy, setBusy] = useState(false);
1176
-
1177
- useEffect(() => {
1178
- lookupPluginPackage(plugin.name)
1179
- .then((found) => {
1180
- setVersions(found.package.versions);
1181
- setLatest(found.package.latest);
1182
- })
1183
- .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
1184
- }, [plugin.name]);
1185
-
1186
- async function save() {
1187
- setBusy(true);
1188
- setError(null);
1189
- const problem = await onSave(selected);
1190
- if (problem) {
1191
- setError(problem);
1192
- setBusy(false);
1193
- }
1194
- }
1195
-
1196
- return (
1197
- <Modal open onClose={onClose} title={`${plugin.manifest.displayName} version`}>
1198
- {error && <Alert>{error}</Alert>}
1199
- {versions === null ? (
1200
- !error && <p className="text-sm text-text-muted">Loading&hellip;</p>
1201
- ) : (
1202
- <div className="flex flex-col gap-3">
1203
- <label className="flex flex-col gap-1.5 text-sm">
1204
- <span className="font-semibold">Version</span>
1205
- <select aria-label="Version" className={INPUT_CLASS} value={selected} onChange={(e) => setSelected(e.target.value)}>
1206
- {versions.map((version) => (
1207
- <option key={version} value={version}>
1208
- {version}
1209
- {version === latest ? " (latest)" : ""}
1210
- {version === plugin.packageVersion ? " (installed)" : ""}
1211
- </option>
1212
- ))}
1213
- </select>
1214
- </label>
1215
- <div className="flex gap-2 justify-end">
1216
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1217
- Cancel
1218
- </Button>
1219
- <Button
1220
- type="button"
1221
- className="!w-auto"
1222
- loading={busy}
1223
- disabled={busy || selected === plugin.packageVersion}
1224
- onClick={() => void save()}
1225
- >
1226
- Save
1227
- </Button>
1228
- </div>
1229
- </div>
1230
- )}
1231
- </Modal>
1232
- );
1233
- }
1234
-
1235
- /** Lists the plugins a change also installs or enables, and makes the change once confirmed. */
1236
- function ConfirmDependenciesModal({
1237
- change,
1238
- displayNameOf,
1239
- onClose,
1240
- onDone,
1241
- }: {
1242
- change: PendingChange;
1243
- displayNameOf: (name: string) => string;
1244
- onClose: () => void;
1245
- onDone: () => void;
1246
- }) {
1247
- const [error, setError] = useState<string | null>(null);
1248
- const [busy, setBusy] = useState(false);
1249
- const { plan } = change;
1250
-
1251
- async function proceed() {
1252
- setBusy(true);
1253
- setError(null);
1254
- try {
1255
- await change.apply(plan);
1256
- onDone();
1257
- } catch (err) {
1258
- setError(errorMessage(err, change.failure));
1259
- setBusy(false);
1260
- }
1261
- }
1262
-
1263
- return (
1264
- <Modal open onClose={onClose} title={`${change.displayName} requires other plugins`}>
1265
- {error && <Alert>{error}</Alert>}
1266
- <p className="text-sm mb-2">
1267
- {change.displayName} {plan.plugin.version} needs these plugins, so they&apos;ll be changed too:
1268
- </p>
1269
- <ul className="text-sm list-disc pl-5 mb-4">
1270
- {plan.install.map((dependency) => (
1271
- <li key={dependency.name}>
1272
- Install {dependency.manifest.displayName} {dependency.version}
1273
- </li>
1274
- ))}
1275
- {plan.enable.map((name) => (
1276
- <li key={name}>Enable {displayNameOf(name)}</li>
1277
- ))}
1278
- </ul>
1279
- <div className="flex gap-2 justify-end">
1280
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1281
- Cancel
1282
- </Button>
1283
- <Button type="button" className="!w-auto" loading={busy} disabled={busy} onClick={() => void proceed()}>
1284
- Continue
1285
- </Button>
1286
- </div>
1287
- </Modal>
1288
- );
1289
- }
1290
-
1291
- /** Whether a required select has no value to fall back on, so the form has to pick (and save) one. */
1292
- function needsSelection(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): boolean {
1293
- return (
1294
- definition.type === "select" &&
1295
- !!definition.required &&
1296
- saved === undefined &&
1297
- definition.default === undefined &&
1298
- (definition.options ?? []).length > 0
1299
- );
1300
- }
1301
-
1302
- /** What a plugin's setting default writes for the host this console was reached at, like `https://<host>/meet`. */
1303
- const HOST_PLACEHOLDER = "<host>";
1304
-
1305
- /**
1306
- * The value offered for a text setting whose default names the host (`https://<host>/meet`) and that has no real value
1307
- * saved yet - nothing, an empty value, or the placeholder itself, which an older server stores when it installs the
1308
- * plugin. It's the default with this console's host filled in, and is only stored once the form is saved.
1309
- */
1310
- function suggestedHost(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): string | undefined {
1311
- if (typeof definition.default !== "string" || !definition.default.includes(HOST_PLACEHOLDER) || typeof window === "undefined") {
1312
- return undefined;
1313
- }
1314
- const unset: boolean = saved === undefined || saved === "" || (typeof saved === "string" && saved.includes(HOST_PLACEHOLDER));
1315
- return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
1316
- }
1317
-
1318
- /** A setting's current form value: its saved value, else its default - or, for a required select with neither, its
1319
- * first option. Kept as a string for text inputs. */
1320
- function initialValue(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): PluginSettingValue | "" {
1321
- if (needsSelection(definition, saved)) {
1322
- return definition.options![0].value;
1323
- }
1324
- const suggestion = suggestedHost(definition, saved);
1325
- if (suggestion !== undefined) {
1326
- return suggestion;
1327
- }
1328
- const value = saved ?? definition.default;
1329
- if (definition.type === "boolean") {
1330
- return value === true;
1331
- }
1332
- return value === undefined ? "" : String(value);
1333
- }
1334
-
1335
- function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
1336
- // Only opened from the Settings button, which is only shown for a plugin that declares settings.
1337
- const definitions: PluginSettingDefinition[] = plugin.manifest.settings!;
1338
- const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
1339
- const [values, setValues] = useState<Record<string, PluginSettingValue | "">>(initial.current);
1340
- const [error, setError] = useState<string | null>(null);
1341
- const [busy, setBusy] = useState(false);
1342
-
1343
- async function save(e: FormEvent) {
1344
- e.preventDefault();
1345
- // The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
1346
- // - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
1347
- // A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
1348
- // form shows them.
1349
- const settings: Record<string, PluginSettingValue | null> = {};
1350
- let changed = false;
1351
- for (const definition of definitions) {
1352
- const key: string = definition.key;
1353
- const value = values[key];
1354
- if (value === initial.current[key] && !needsSelection(definition, plugin.settings[key]) && suggestedHost(definition, plugin.settings[key]) === undefined) {
1355
- settings[key] = plugin.settings[key] ?? null;
1356
- continue;
1357
- }
1358
- changed = true;
1359
- // A number input only ever reports a valid number or an empty string.
1360
- settings[key] = value === "" ? null : definition.type === "number" ? Number(value) : value;
1361
- }
1362
- if (!changed) {
1363
- onClose();
1364
- return;
1365
- }
1366
- setBusy(true);
1367
- setError(null);
1368
- try {
1369
- onSaved(await updatePlugin(plugin.uid, { version: plugin.version, settings }));
1370
- } catch (err) {
1371
- setError(errorMessage(err, "Could not save the settings."));
1372
- } finally {
1373
- setBusy(false);
1374
- }
1375
- }
1376
-
1377
- return (
1378
- <Modal open onClose={onClose} title={`${plugin.manifest.displayName} settings`}>
1379
- {error && <Alert>{error}</Alert>}
1380
- <form onSubmit={save} className="flex flex-col gap-5">
1381
- {definitions.map((definition) => (
1382
- <SettingField
1383
- key={definition.key}
1384
- definition={definition}
1385
- value={values[definition.key]}
1386
- onChange={(value) => setValues((prev) => ({ ...prev, [definition.key]: value }))}
1387
- />
1388
- ))}
1389
- {/* Kept in view at the bottom of the dialog while a long list of settings scrolls. The dialog scrolls
1390
- inside its padding, so the bar reaches past it to the dialog's edges. */}
1391
- <div className="sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3">
1392
- <p className="text-xs text-text-muted">Saving restarts the servers one at a time to apply the new settings.</p>
1393
- <div className="flex gap-2 justify-end">
1394
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1395
- Cancel
1396
- </Button>
1397
- <Button type="submit" className="!w-auto" loading={busy} disabled={busy}>
1398
- Save
1399
- </Button>
1400
- </div>
1401
- </div>
1402
- </form>
1403
- </Modal>
1404
- );
1405
- }
1406
-
1407
- function SettingField({
1408
- definition,
1409
- value,
1410
- onChange,
1411
- }: {
1412
- definition: PluginSettingDefinition;
1413
- value: PluginSettingValue | "";
1414
- onChange: (value: PluginSettingValue | "") => void;
1415
- }) {
1416
- const help = definition.help && <span className="text-xs text-text-muted">{definition.help}</span>;
1417
- if (definition.type === "boolean") {
1418
- // Laid out like the admin policy forms' checkboxes: the help sits under the label, beside the box.
1419
- return (
1420
- <label className="flex items-start gap-2 text-sm">
1421
- <input type="checkbox" className="mt-1" checked={value === true} onChange={(e) => onChange(e.target.checked)} />
1422
- <span className="flex flex-col gap-0.5">
1423
- <span className="font-semibold">{definition.label}</span>
1424
- {help}
1425
- </span>
1426
- </label>
1427
- );
1428
- }
1429
- return (
1430
- <label className="flex flex-col gap-1.5 text-sm">
1431
- <span className="font-semibold">{definition.label}</span>
1432
- {definition.type === "select" ? (
1433
- <select aria-label={definition.label} className={INPUT_CLASS} value={String(value)} onChange={(e) => onChange(e.target.value)}>
1434
- {!definition.required && <option value="">Default</option>}
1435
- {(definition.options ?? []).map((option) => (
1436
- <option key={option.value} value={option.value}>
1437
- {option.label}
1438
- </option>
1439
- ))}
1440
- </select>
1441
- ) : (
1442
- <input
1443
- aria-label={definition.label}
1444
- className={INPUT_CLASS}
1445
- type={definition.type === "number" ? "number" : "text"}
1446
- min={definition.min}
1447
- max={definition.max}
1448
- required={definition.required}
1449
- value={String(value)}
1450
- onChange={(e) => onChange(e.target.value)}
1451
- />
1452
- )}
1453
- {help}
1454
- </label>
1455
- );
1456
- }
1457
-
1458
- function RemoveModal({ plugin, onClose, onRemoved }: { plugin: Plugin; onClose: () => void; onRemoved: (result: RemovePluginResult) => void }) {
1459
- const [error, setError] = useState<string | null>(null);
1460
- const [busy, setBusy] = useState(false);
1461
- const [purgeData, setPurgeData] = useState(false);
1462
- const [typed, setTyped] = useState("");
1463
- const helpId = useId();
1464
- const displayName = plugin.manifest.displayName;
1465
- // Deleting data is destructive and permanent, so it also takes the plugin's name typed out.
1466
- const confirmed = !purgeData || typed.trim().toLowerCase() === displayName.trim().toLowerCase();
1467
-
1468
- async function remove(event: FormEvent) {
1469
- event.preventDefault();
1470
- setBusy(true);
1471
- setError(null);
1472
- try {
1473
- onRemoved(await removePlugin(plugin.uid, { purgeData }));
1474
- } catch (err) {
1475
- setError(isElevationRequired(err) ? ELEVATION_MESSAGE : errorMessage(err, "Could not uninstall the plugin."));
1476
- setBusy(false);
1477
- }
1478
- }
1479
-
1480
- return (
1481
- <Modal open onClose={onClose} title={`Uninstall ${displayName}?`}>
1482
- <form onSubmit={(event) => void remove(event)}>
1483
- {error && <Alert>{error}</Alert>}
1484
- <p className="text-sm mb-4">
1485
- {purgeData
1486
- ? "The servers stop running this plugin after they restart, and then all the data it stored is deleted."
1487
- : "The servers stop running this plugin after they restart. Data it stored stays in the database, and adding the plugin again brings it back."}
1488
- </p>
1489
- <div className="mb-4">
1490
- <label className="flex items-start gap-2 text-sm font-semibold cursor-pointer">
1491
- <input
1492
- type="checkbox"
1493
- className="mt-0.5 h-4 w-4 shrink-0 accent-danger"
1494
- checked={purgeData}
1495
- disabled={busy}
1496
- aria-describedby={helpId}
1497
- onChange={(e) => setPurgeData(e.target.checked)}
1498
- />
1499
- <span>Also delete all data this plugin stored</span>
1500
- </label>
1501
- <div id={helpId} className="mt-2 pl-6 text-xs text-text-muted">
1502
- <p>What is deleted:</p>
1503
- <ul className="list-disc pl-5 mt-1 space-y-0.5">
1504
- <li>the database collections and tables the plugin&apos;s features use, with everything in them</li>
1505
- <li>the plugin&apos;s saved settings</li>
1506
- <li>its downloaded package and cached pages on the servers</li>
1507
- <li>whatever else the plugin cleans up itself, such as files it stored or data kept outside the database</li>
1508
- </ul>
1509
- <p className="mt-2 text-danger font-semibold">This can&apos;t be undone.</p>
1510
- </div>
1511
- </div>
1512
- {purgeData && (
1513
- <>
1514
- <p className="text-xs text-text-muted mb-3">
1515
- Nothing is deleted while a server is still running the plugin. Once every server has restarted without it, usually
1516
- within a few minutes, its data is deleted. Adding the plugin again before then cancels the deletion.
1517
- </p>
1518
- <label className="block text-sm mb-4">
1519
- <span>
1520
- Type <strong>{displayName}</strong> to confirm
1521
- </span>
1522
- <input
1523
- type="text"
1524
- className={`${INPUT_CLASS} mt-1.5`}
1525
- value={typed}
1526
- autoComplete="off"
1527
- spellCheck={false}
1528
- disabled={busy}
1529
- onChange={(e) => setTyped(e.target.value)}
1530
- />
1531
- </label>
1532
- </>
1533
- )}
1534
- <div className="flex gap-2 justify-end">
1535
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1536
- Cancel
1537
- </Button>
1538
- <Button type="submit" className={purgeData ? DANGER_BUTTON_CLASS : "!w-auto"} loading={busy} disabled={busy || !confirmed}>
1539
- {purgeData ? "Uninstall and delete data" : "Uninstall"}
1540
- </Button>
1541
- </div>
1542
- </form>
1543
- </Modal>
1544
- );
1545
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useCallback, useEffect, useId, useRef, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ addPlugin,
9
+ expectedPlanOf,
10
+ getPluginStatus,
11
+ getPluginUpdates,
12
+ listPluginNamespaces,
13
+ listPlugins,
14
+ lookupPluginPackage,
15
+ planPluginChange,
16
+ Plugin,
17
+ PluginChangePlan,
18
+ PluginConfiguredSetting,
19
+ PluginInstanceStatus,
20
+ PluginNamespace,
21
+ PluginPurgeInfo,
22
+ PluginPurgeState,
23
+ PluginRegistryLookup,
24
+ PluginSearchResult,
25
+ PluginSettingDefinition,
26
+ PluginSettingValue,
27
+ PluginStatus,
28
+ PluginUpdateInfo,
29
+ removePlugin,
30
+ RemovePluginResult,
31
+ retryPluginPurge,
32
+ searchPlugins,
33
+ updatePlugin,
34
+ } from "@rapidmx/react-shared/admin/pluginsApi.js";
35
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
36
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
37
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
38
+ import { notify } from "../../../notifications/store.js";
39
+ import { isElevationRequired } from "../elevation.js";
40
+ import { getAllowPrerelease, setAllowPrerelease } from "./pluginPreferences.js";
41
+
42
+ const INPUT_CLASS =
43
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
44
+
45
+ /** How often status is refreshed while server copies are still applying a change. */
46
+ const PENDING_POLL_MS = 5000;
47
+
48
+ /** How long status keeps being polled after a saved change, since servers may not have noticed it yet. */
49
+ const AFTER_CHANGE_POLL_MS = 2 * 60 * 1000;
50
+
51
+ /** The longest wait between retries while no status has ever been read. */
52
+ const MAX_STATUS_RETRY_MS = 60 * 1000;
53
+
54
+ function errorMessage(err: unknown, fallback: string): string {
55
+ return err instanceof ApiRequestError ? err.message : fallback;
56
+ }
57
+
58
+ /** Runs `action`, resolving why it failed, or `null`. */
59
+ async function attempt(action: () => Promise<void>, failure: string): Promise<string | null> {
60
+ try {
61
+ await action();
62
+ return null;
63
+ } catch (err) {
64
+ return errorMessage(err, failure);
65
+ }
66
+ }
67
+
68
+ /** The classes of a confirm button that deletes data, the same as the app's other destructive buttons. */
69
+ const DANGER_BUTTON_CLASS = "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger";
70
+
71
+ /** Shown when the server wants the administrator to have confirmed their identity recently (`api-104`). */
72
+ const ELEVATION_MESSAGE = "Deleting data needs you to have recently confirmed your identity. Reload this page, or sign in again, then try once more.";
73
+
74
+ /** What an administrator sees an uninstalled plugin's data deletion as, without the display name of a plugin that's gone. */
75
+ const purgeName = (purge: PluginPurgeInfo): string => purge.displayName ?? purge.name;
76
+
77
+ /** Why a data deletion failed: what the server recorded, else the steps that failed. */
78
+ function purgeFailure(purge: PluginPurgeInfo): string {
79
+ return purge.error ?? (purge.steps.filter((step) => !step.ok).map((step) => step.error ?? step.step).join("; ") || "The reason wasn't recorded.");
80
+ }
81
+
82
+ /** A day, in the administrator's own format. */
83
+ const formatDate = (iso: string): string => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
84
+
85
+ /** A change waiting for the administrator to confirm the other plugins it also installs or enables. */
86
+ interface PendingChange {
87
+ displayName: string;
88
+ plan: PluginChangePlan;
89
+ apply: (plan: PluginChangePlan) => Promise<void>;
90
+ failure: string;
91
+ /** The installed plugin being changed, which stays busy while the change waits for confirmation. */
92
+ uid?: string;
93
+ }
94
+
95
+ /** An action the toolbar above the installed plugins applies to several plugins at once. */
96
+ type BulkKind = "upgrade" | "disable" | "uninstall";
97
+
98
+ const BULK_WORDS: Record<BulkKind, { doing: string; done: string }> = {
99
+ upgrade: { doing: "Upgrading", done: "upgraded" },
100
+ disable: { doing: "Disabling", done: "disabled" },
101
+ uninstall: { doing: "Uninstalling", done: "uninstalled" },
102
+ };
103
+
104
+ /** How far a bulk action has got. */
105
+ interface BulkProgress {
106
+ kind: BulkKind;
107
+ /** How many plugins were finished, failures included. */
108
+ done: number;
109
+ total: number;
110
+ /** The plugin being changed now. */
111
+ current: string;
112
+ }
113
+
114
+ /** Why some plugins of a bulk action were left as they were. */
115
+ interface BulkFailure {
116
+ title: string;
117
+ reasons: string[];
118
+ }
119
+
120
+ const pluralPlugins = (count: number): string => `${count} ${count === 1 ? "plugin" : "plugins"}`;
121
+
122
+ /**
123
+ * `plugins` in the order a bulk action has to change them in, since a plugin that others require can't be disabled or
124
+ * uninstalled while they're enabled, and one that requires others may need them at their new versions first: each
125
+ * plugin comes after the ones it requires (`dependenciesFirst`, for an upgrade) or before them (for the rest).
126
+ */
127
+ function orderByRequirements(plugins: Plugin[], dependenciesFirst: boolean): Plugin[] {
128
+ const byName: Map<string, Plugin> = new Map(plugins.map((plugin) => [plugin.name, plugin]));
129
+ const ordered: Plugin[] = [];
130
+ const seen: Set<string> = new Set();
131
+ const visit = (plugin: Plugin) => {
132
+ if (seen.has(plugin.name)) {
133
+ return;
134
+ }
135
+ seen.add(plugin.name);
136
+ for (const required of Object.keys(plugin.manifest.requires ?? {})) {
137
+ const dependency: Plugin | undefined = byName.get(required);
138
+ if (dependency) {
139
+ visit(dependency);
140
+ }
141
+ }
142
+ ordered.push(plugin);
143
+ };
144
+ plugins.forEach(visit);
145
+ return dependenciesFirst ? ordered : ordered.reverse();
146
+ }
147
+
148
+ /**
149
+ * The bar that runs across the top of the window while plugins are being changed, so it's plain that something is still
150
+ * happening: it slides endlessly, since how long a registry lookup or an install takes isn't known. It stands still
151
+ * (as a faded full bar) when the user asks for less motion.
152
+ */
153
+ function PluginProgressBar({ label }: { label: string }) {
154
+ return (
155
+ <div role="progressbar" aria-label={label} className="fixed inset-x-0 top-0 z-[1100] h-1 overflow-hidden bg-primary/20">
156
+ <div className="rr-progress-indeterminate h-full w-1/3 rounded-pill bg-primary motion-reduce:w-full motion-reduce:opacity-40" />
157
+ </div>
158
+ );
159
+ }
160
+
161
+ /** Classes for a plugin table's header and body cells. */
162
+ const TH_CLASS = "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border";
163
+ const TD_CLASS = "py-3 px-2.5 border-b border-border align-top";
164
+
165
+ /**
166
+ * The installed plugins table stacks each row on narrow screens - name and description, then version and status side
167
+ * by side, then the actions - so the actions aren't scrolled off to the side. The explicit roles keep it a table for
168
+ * assistive technology while it isn't displayed as one.
169
+ */
170
+ const INSTALLED_ROW_CLASS = "flex flex-wrap border-b border-border lg:table-row lg:border-b-0";
171
+ const INSTALLED_TD_CLASS = "block px-2.5 align-top lg:table-cell lg:pt-3 lg:pb-3 lg:border-b lg:border-border";
172
+
173
+ /** The small pill badge admin tables use for a state. */
174
+ const BADGE_CLASS = "inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill";
175
+
176
+ export interface PluginsManagerProps {
177
+ /**
178
+ * Set when shown inside another page that already has its own heading and introduction (the setup wizard): the
179
+ * page heading and introduction are left out, and the section headings sit one level lower.
180
+ */
181
+ embedded?: boolean;
182
+ }
183
+
184
+ /** Installed plugins with their rollout status, and every plugin action - shared by the Plugins page and the
185
+ * setup wizard. */
186
+ export default function PluginsManager({ embedded = false }: PluginsManagerProps = {}) {
187
+ const [plugins, setPlugins] = useState<Plugin[]>([]);
188
+ const [status, setStatus] = useState<PluginStatus | null>(null);
189
+ /** Set when the last status refresh failed, so what's shown may be out of date. */
190
+ const [statusStale, setStatusStale] = useState(false);
191
+ /** When polling started by a saved change stops (epoch ms), or 0 when there's none. */
192
+ const [pollUntil, setPollUntil] = useState(0);
193
+ const [updates, setUpdates] = useState<Map<string, PluginUpdateInfo>>(new Map());
194
+ const [loading, setLoading] = useState(true);
195
+ const [error, setError] = useState<string | null>(null);
196
+ /** The installed plugin whose enable just failed, offered a retry next to `error`. */
197
+ const [retryEnableUid, setRetryEnableUid] = useState<string | null>(null);
198
+ const [adding, setAdding] = useState(false);
199
+ const [upgrading, setUpgrading] = useState<Plugin | null>(null);
200
+ const [configuring, setConfiguring] = useState<Plugin | null>(null);
201
+ const [removing, setRemoving] = useState<Plugin | null>(null);
202
+ const [confirming, setConfirming] = useState<PendingChange | null>(null);
203
+ /** Installed plugins with an action under way. */
204
+ const [busyUids, setBusyUids] = useState<ReadonlySet<string>>(new Set());
205
+ /** Set when re-reading the list after a change failed, so rows may not show what that change also did. */
206
+ const [listStale, setListStale] = useState(false);
207
+ /** How many times status has been retried while none has ever been read. */
208
+ const [statusRetries, setStatusRetries] = useState(0);
209
+ /** Whether prerelease versions are offered (a preference of this browser). */
210
+ const [allowPrerelease, setAllowPrereleaseState] = useState<boolean>(getAllowPrerelease);
211
+ /** The same, for `refreshUpdates()`, which must stay the same function for the effects that depend on it. */
212
+ const allowPrereleaseRef = useRef(allowPrerelease);
213
+ /** Identifies the latest update check, so a slower earlier one can't replace what it found. */
214
+ const updatesRequest = useRef(0);
215
+ /** The plugins ticked for a bulk action. */
216
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
217
+ /** Set while a bulk action runs. */
218
+ const [bulk, setBulk] = useState<BulkProgress | null>(null);
219
+ const [bulkFailure, setBulkFailure] = useState<BulkFailure | null>(null);
220
+ /** The plugins waiting for the administrator to confirm uninstalling them together. */
221
+ const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState<Plugin[] | null>(null);
222
+ /** How many changes (or the lookups that come before them) are waiting on the server. */
223
+ const [working, setWorking] = useState(0);
224
+ const statusRequest = useRef<Promise<void> | null>(null);
225
+ /** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
226
+ const seenPurges = useRef<Map<string, PluginPurgeState>>(new Map());
227
+
228
+ const setBusy = (uid: string, busy: boolean) =>
229
+ setBusyUids((prev) => {
230
+ const next = new Set(prev);
231
+ if (busy) {
232
+ next.add(uid);
233
+ } else {
234
+ next.delete(uid);
235
+ }
236
+ return next;
237
+ });
238
+
239
+ const refreshStatus = useCallback(() => {
240
+ // One read at a time: a poll that comes due while a slow read is still running shares it.
241
+ statusRequest.current ??= getPluginStatus()
242
+ .then((next) => {
243
+ setStatus(next);
244
+ setStatusStale(false);
245
+ })
246
+ // Status is advisory - a failure to read it shouldn't hide the plugin list, or lose the last status read.
247
+ .catch(() => setStatusStale(true))
248
+ .finally(() => {
249
+ statusRequest.current = null;
250
+ });
251
+ return statusRequest.current;
252
+ }, []);
253
+
254
+ /** Checks the registry for newer versions. Resolves what it found, or `undefined` when it couldn't check. */
255
+ const refreshUpdates = useCallback((): Promise<PluginUpdateInfo[] | undefined> => {
256
+ const token: number = ++updatesRequest.current;
257
+ // Also advisory: a registry that can't be reached just means no update badges.
258
+ return getPluginUpdates({ prerelease: allowPrereleaseRef.current })
259
+ .then((list): PluginUpdateInfo[] | undefined => {
260
+ if (token === updatesRequest.current) {
261
+ setUpdates(new Map(list.map((info) => [info.uid, info])));
262
+ }
263
+ return list;
264
+ })
265
+ .catch((): PluginUpdateInfo[] | undefined => {
266
+ if (token === updatesRequest.current) {
267
+ setUpdates(new Map());
268
+ }
269
+ return undefined;
270
+ });
271
+ }, []);
272
+
273
+ /** Keeps `work` counted as in progress, which is what the bar across the top of the window shows. */
274
+ const track = useCallback(async <R,>(work: Promise<R>): Promise<R> => {
275
+ setWorking((count) => count + 1);
276
+ try {
277
+ return await work;
278
+ } finally {
279
+ setWorking((count) => count - 1);
280
+ }
281
+ }, []);
282
+
283
+ function changePrerelease(allow: boolean) {
284
+ setAllowPrerelease(allow);
285
+ allowPrereleaseRef.current = allow;
286
+ setAllowPrereleaseState(allow);
287
+ // What counts as an update depends on it.
288
+ void refreshUpdates();
289
+ }
290
+
291
+ /** Re-reads the list, for changes that also installed or enabled other plugins. */
292
+ const reload = useCallback(() => {
293
+ return listPlugins()
294
+ .then((list) => {
295
+ setPlugins(list);
296
+ setListStale(false);
297
+ })
298
+ .catch(() => setListStale(true));
299
+ }, []);
300
+
301
+ useEffect(() => {
302
+ Promise.all([listPlugins(), refreshStatus()])
303
+ .then(([list]) => {
304
+ setPlugins(list);
305
+ void refreshUpdates();
306
+ })
307
+ .catch((err) => setError(errorMessage(err, "Could not load plugins.")))
308
+ .finally(() => setLoading(false));
309
+ }, [refreshStatus, refreshUpdates]);
310
+
311
+ const pending: boolean = !!status && status.instances.some((instance) => instance.hash !== status.hash);
312
+ // A plugin's data deletion in the status that isn't over yet, for a plugin that's uninstalled (one added again is hidden).
313
+ const installedNames: Set<string> = new Set(plugins.map((plugin) => plugin.name));
314
+ const purges: PluginPurgeInfo[] = (status?.purges ?? []).filter((purge) => !installedNames.has(purge.name));
315
+ const purging: boolean = purges.some((purge) => purge.state === "pending" || purge.state === "running");
316
+ useEffect(() => {
317
+ if (!pending && !purging && pollUntil === 0) {
318
+ return;
319
+ }
320
+ const timer = setInterval(() => void refreshStatus(), PENDING_POLL_MS);
321
+ // Polling for a saved change ends after a while; a rollout that's under way keeps it going.
322
+ const stop = pollUntil > 0 ? setTimeout(() => setPollUntil(0), Math.max(0, pollUntil - Date.now())) : undefined;
323
+ return () => {
324
+ clearInterval(timer);
325
+ clearTimeout(stop);
326
+ };
327
+ }, [pending, purging, pollUntil, refreshStatus]);
328
+
329
+ // A data deletion seen under way that has now ended is worth a notification: it happens after the servers restart,
330
+ // long after the administrator asked for it.
331
+ useEffect(() => {
332
+ for (const purge of status?.purges ?? []) {
333
+ const before = seenPurges.current.get(purge.uid);
334
+ seenPurges.current.set(purge.uid, purge.state);
335
+ if (before !== "pending" && before !== "running") {
336
+ continue;
337
+ }
338
+ if (purge.state === "done") {
339
+ notify({ kind: "success", title: `${purgeName(purge)}'s data was deleted`, dedupeKey: `plugin-purge-${purge.uid}-done` });
340
+ } else if (purge.state === "failed") {
341
+ notify({
342
+ kind: "error",
343
+ title: `Deleting ${purgeName(purge)}'s data failed`,
344
+ message: purgeFailure(purge),
345
+ dedupeKey: `plugin-purge-${purge.uid}-failed`,
346
+ });
347
+ }
348
+ }
349
+ }, [status]);
350
+
351
+ // With no status read at all there's nothing pending to poll for, so keep retrying - less often each time.
352
+ const neverRead: boolean = !status && statusStale;
353
+ useEffect(() => {
354
+ if (!neverRead) {
355
+ return;
356
+ }
357
+ const timer = setTimeout(
358
+ () => void refreshStatus().then(() => setStatusRetries((n) => n + 1)),
359
+ Math.min(PENDING_POLL_MS * 2 ** statusRetries, MAX_STATUS_RETRY_MS),
360
+ );
361
+ return () => clearTimeout(timer);
362
+ }, [neverRead, statusRetries, refreshStatus]);
363
+
364
+ /** Re-reads status, and keeps doing so for a while, since saving starts a rollout servers pick up shortly. */
365
+ function watchRollout() {
366
+ setPollUntil(Date.now() + AFTER_CHANGE_POLL_MS);
367
+ void refreshStatus();
368
+ }
369
+
370
+ /** Applies saved changes locally and re-reads status and updates, since saving starts a rollout. */
371
+ function applied(...changed: Plugin[]) {
372
+ setPlugins((prev) => {
373
+ let next = prev;
374
+ for (const updated of changed) {
375
+ const exists = next.some((plugin) => plugin.uid === updated.uid);
376
+ next = exists ? next.map((plugin) => (plugin.uid === updated.uid ? updated : plugin)) : [...next, updated];
377
+ }
378
+ return [...next].sort((a, b) => a.name.localeCompare(b.name));
379
+ });
380
+ watchRollout();
381
+ void refreshUpdates();
382
+ }
383
+
384
+ /** Runs an action on a plugin's row (an installed plugin's, or an uninstalled one's), which is busy meanwhile, and
385
+ * shows why it failed. */
386
+ async function onRow(plugin: { uid: string }, action: () => Promise<string | null>) {
387
+ setBusy(plugin.uid, true);
388
+ setError(null);
389
+ setRetryEnableUid(null);
390
+ const problem = await action();
391
+ if (problem) {
392
+ setError(problem);
393
+ }
394
+ // A change waiting for confirmation keeps the row busy by itself, until the confirmation closes.
395
+ setBusy(plugin.uid, false);
396
+ }
397
+
398
+ /** Disables an installed plugin, resolving why it couldn't be, or `null`. */
399
+ function disable(plugin: Plugin): Promise<string | null> {
400
+ return attempt(
401
+ async () => applied(await track(updatePlugin(plugin.uid, { version: plugin.version, enabled: false }))),
402
+ `Could not disable ${plugin.manifest.displayName}.`,
403
+ );
404
+ }
405
+
406
+ function toggle(plugin: Plugin) {
407
+ const displayName: string = plugin.manifest.displayName;
408
+ if (plugin.enabled) {
409
+ return onRow(plugin, () => disable(plugin));
410
+ }
411
+ // Enabling a plugin also installs or enables the plugins it requires, so it's previewed like any other change.
412
+ // The installed version is planned from its stored manifest, so this doesn't need the registry. A preview that
413
+ // fails is never skipped - enabling without one could install or enable other plugins the administrator never
414
+ // saw - so the failure is shown with a retry instead.
415
+ return onRow(plugin, async () => {
416
+ const problem = await planned(
417
+ plugin.name,
418
+ plugin.packageVersion,
419
+ displayName,
420
+ async (plan) => {
421
+ applied(await track(updatePlugin(plugin.uid, { version: plugin.version, enabled: true, expectedPlan: expectedPlanOf(plan) })));
422
+ if (plan.install.length > 0 || plan.enable.length > 0) {
423
+ void reload();
424
+ }
425
+ },
426
+ `Could not enable ${displayName}.`,
427
+ { uid: plugin.uid },
428
+ );
429
+ if (problem) {
430
+ setRetryEnableUid(plugin.uid);
431
+ }
432
+ return problem;
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Checks what installing or changing a plugin also takes before doing it. Resolves why it can't be done, or `null`
438
+ * once it's done - or, when it also installs or enables other plugins, once they're shown for confirmation.
439
+ */
440
+ async function planned(
441
+ name: string,
442
+ packageVersion: string,
443
+ displayName: string,
444
+ apply: (plan: PluginChangePlan) => Promise<void>,
445
+ failure: string,
446
+ options: {
447
+ uid?: string;
448
+ /** Part of a bulk action, which has no one to confirm the other plugins the change would also install or enable:
449
+ * such a change isn't made, and says why. */
450
+ unattended?: boolean;
451
+ } = {},
452
+ ): Promise<string | null> {
453
+ let plan: PluginChangePlan;
454
+ try {
455
+ plan = await track(planPluginChange(name, packageVersion));
456
+ } catch (err) {
457
+ return errorMessage(err, failure);
458
+ }
459
+ if (plan.conflicts.length > 0) {
460
+ return `${displayName} ${plan.plugin.version} can't be installed. ${plan.conflicts.join(" ")}`;
461
+ }
462
+ if (plan.install.length > 0 || plan.enable.length > 0) {
463
+ if (options.unattended) {
464
+ 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.`;
465
+ }
466
+ setConfirming({ displayName, plan, apply, failure, uid: options.uid });
467
+ return null;
468
+ }
469
+ return attempt(() => apply(plan), failure);
470
+ }
471
+
472
+ function install(name: string, packageVersion: string, displayName: string) {
473
+ return planned(
474
+ name,
475
+ packageVersion,
476
+ displayName,
477
+ async (plan) => {
478
+ const result = await track(addPlugin(name, packageVersion, expectedPlanOf(plan)));
479
+ applied(...result.dependencies, result.plugin);
480
+ // Adding a plugin cancels the deletion of its data that was waiting for the servers to stop running it.
481
+ for (const warning of result.warnings ?? []) {
482
+ notify({ kind: "warning", title: "Data deletion cancelled", message: warning });
483
+ }
484
+ },
485
+ `Could not install ${displayName}.`,
486
+ );
487
+ }
488
+
489
+ function changeVersion(plugin: Plugin, packageVersion: string, unattended = false) {
490
+ const failure = `Could not upgrade ${plugin.manifest.displayName}.`;
491
+ if (!plugin.enabled) {
492
+ // A disabled plugin stays disabled, so nothing it requires is installed or enabled - there's nothing to preview.
493
+ return attempt(async () => applied(await track(updatePlugin(plugin.uid, { version: plugin.version, packageVersion }))), failure);
494
+ }
495
+ return planned(
496
+ plugin.name,
497
+ packageVersion,
498
+ plugin.manifest.displayName,
499
+ async (plan) => {
500
+ applied(await track(updatePlugin(plugin.uid, { version: plugin.version, packageVersion, expectedPlan: expectedPlanOf(plan) })));
501
+ if (plan.install.length > 0 || plan.enable.length > 0) {
502
+ void reload();
503
+ }
504
+ },
505
+ failure,
506
+ { uid: plugin.uid, unattended },
507
+ );
508
+ }
509
+
510
+ function upgrade(plugin: Plugin, packageVersion: string) {
511
+ return onRow(plugin, () => changeVersion(plugin, packageVersion));
512
+ }
513
+
514
+ /** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
515
+ function reinstall(purge: PluginPurgeInfo) {
516
+ const displayName: string = purgeName(purge);
517
+ return onRow(purge, async () => {
518
+ let latest: string;
519
+ try {
520
+ latest = (await track(planPluginChange(purge.name, undefined, { prerelease: allowPrerelease }))).plugin.version;
521
+ } catch (err) {
522
+ return errorMessage(err, `Could not install ${displayName}.`);
523
+ }
524
+ return install(purge.name, latest, displayName);
525
+ });
526
+ }
527
+
528
+ /** Runs the steps of a failed data deletion that failed, again. */
529
+ async function retryPurge(purge: PluginPurgeInfo) {
530
+ setBusy(purge.uid, true);
531
+ setError(null);
532
+ setRetryEnableUid(null);
533
+ try {
534
+ await retryPluginPurge(purge.uid);
535
+ // The retry starts on a server shortly; keep reading status until it ends.
536
+ setPollUntil(Date.now() + AFTER_CHANGE_POLL_MS);
537
+ await refreshStatus();
538
+ } catch (err) {
539
+ setError(isElevationRequired(err) ? ELEVATION_MESSAGE : errorMessage(err, `Could not retry deleting ${purgeName(purge)}'s data.`));
540
+ }
541
+ setBusy(purge.uid, false);
542
+ }
543
+
544
+ /** Lists a data deletion the server just scheduled. Shown at once; the status polling that follows replaces it with
545
+ * what the servers report. */
546
+ function showScheduledPurge(scheduled: PluginPurgeInfo | undefined) {
547
+ setStatus((prev) =>
548
+ prev && scheduled ? { ...prev, purges: [...(prev.purges ?? []).filter((purge) => purge.uid !== scheduled.uid), scheduled] } : prev,
549
+ );
550
+ }
551
+
552
+ /** Uninstalls an installed plugin, and with `purgeData` deletes the data it stored once no server runs it. Resolves
553
+ * why it couldn't be, or `null`. */
554
+ async function uninstall(plugin: Plugin, purgeData: boolean): Promise<string | null> {
555
+ try {
556
+ const result = await removePlugin(plugin.uid, { purgeData });
557
+ setPlugins((prev) => prev.filter((other) => other.uid !== plugin.uid));
558
+ if (result.purgeScheduled) {
559
+ showScheduledPurge(result.purge);
560
+ }
561
+ return null;
562
+ } catch (err) {
563
+ return isElevationRequired(err) ? ELEVATION_MESSAGE : errorMessage(err, `Could not uninstall ${plugin.manifest.displayName}.`);
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Applies `kind` to `targets` one after another, in the order their requirements allow, and says what became of
569
+ * them. A plugin that can't be changed is left as it was and the rest carry on. For an upgrade, `versions` holds the
570
+ * version each plugin goes to; for an uninstall, `purgeData` also deletes what each stored. Deleting data needs the
571
+ * administrator to have confirmed their identity recently, which no plugin will get past if one is refused for it,
572
+ * so the rest aren't tried.
573
+ */
574
+ async function runBulk(kind: BulkKind, targets: Plugin[], options: { versions?: ReadonlyMap<string, string>; purgeData?: boolean } = {}) {
575
+ const { versions = new Map<string, string>(), purgeData = false } = options;
576
+ const ordered: Plugin[] = orderByRequirements(targets, kind === "upgrade");
577
+ const reasons: string[] = [];
578
+ const failed: Set<string> = new Set();
579
+ setError(null);
580
+ setRetryEnableUid(null);
581
+ setBulkFailure(null);
582
+ await track(
583
+ (async () => {
584
+ for (const [index, plugin] of ordered.entries()) {
585
+ setBulk({ kind, done: index, total: ordered.length, current: plugin.manifest.displayName });
586
+ const problem: string | null =
587
+ kind === "upgrade"
588
+ ? await changeVersion(plugin, versions.get(plugin.uid)!, true)
589
+ : kind === "disable"
590
+ ? await disable(plugin)
591
+ : await uninstall(plugin, purgeData);
592
+ if (!problem) {
593
+ continue;
594
+ }
595
+ failed.add(plugin.uid);
596
+ reasons.push(`${plugin.manifest.displayName}: ${problem}`);
597
+ if (problem === ELEVATION_MESSAGE) {
598
+ for (const untried of ordered.slice(index + 1)) {
599
+ failed.add(untried.uid);
600
+ reasons.push(`${untried.manifest.displayName}: Not attempted.`);
601
+ }
602
+ break;
603
+ }
604
+ }
605
+ })(),
606
+ );
607
+ setBulk(null);
608
+ setSelected(failed);
609
+ const succeeded: number = ordered.length - failed.size;
610
+ if (kind === "uninstall" && succeeded > 0) {
611
+ watchRollout();
612
+ }
613
+ if (succeeded > 0) {
614
+ notify({
615
+ kind: "success",
616
+ title: `${pluralPlugins(succeeded)} ${BULK_WORDS[kind].done}`,
617
+ message: purgeData ? "Their data is deleted once every server has stopped running them. Adding a plugin again before then cancels its deletion." : undefined,
618
+ });
619
+ }
620
+ if (reasons.length > 0) {
621
+ setBulkFailure({ title: `${pluralPlugins(reasons.length)} couldn't be ${BULK_WORDS[kind].done}:`, reasons });
622
+ }
623
+ }
624
+
625
+ /** Upgrades every plugin that has an update, whatever is ticked. Checks the registry first, so it's never working
626
+ * from an out-of-date list of updates. */
627
+ async function upgradeAll() {
628
+ setError(null);
629
+ setBulkFailure(null);
630
+ const found: PluginUpdateInfo[] | undefined = await track(refreshUpdates());
631
+ if (!found) {
632
+ setError("Could not check for plugin updates.");
633
+ return;
634
+ }
635
+ const versions: Map<string, string> = new Map();
636
+ for (const info of found) {
637
+ if (info.updateAvailable && info.latestVersion) {
638
+ versions.set(info.uid, info.latestVersion);
639
+ }
640
+ }
641
+ const targets: Plugin[] = plugins.filter((plugin) => versions.has(plugin.uid));
642
+ if (targets.length === 0) {
643
+ const unchecked: number = found.filter((info) => info.error).length;
644
+ notify(
645
+ unchecked > 0
646
+ ? { kind: "warning", title: "No updates found", message: `${pluralPlugins(unchecked)} couldn't be checked against the registry.` }
647
+ : { kind: "info", title: "All plugins are up to date" },
648
+ );
649
+ return;
650
+ }
651
+ await runBulk("upgrade", targets, { versions });
652
+ }
653
+
654
+ const toggleSelected = (uid: string) =>
655
+ setSelected((prev) => {
656
+ const next = new Set(prev);
657
+ if (!next.delete(uid)) {
658
+ next.add(uid);
659
+ }
660
+ return next;
661
+ });
662
+
663
+ const selectedPlugins: Plugin[] = plugins.filter((plugin) => selected.has(plugin.uid));
664
+ const allSelected: boolean = plugins.length > 0 && selectedPlugins.length === plugins.length;
665
+ const selectedUpgrades: Plugin[] = selectedPlugins.filter((plugin) => updates.get(plugin.uid)?.updateAvailable);
666
+ const selectedEnabled: Plugin[] = selectedPlugins.filter((plugin) => plugin.enabled);
667
+
668
+ // Retried against the plugin as it's listed now (a newer version, or already enabled elsewhere, hides the retry).
669
+ const retryEnable: Plugin | undefined = plugins.find((plugin) => plugin.uid === retryEnableUid && !plugin.enabled);
670
+
671
+ const displayNameOf = (name: string): string => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
672
+
673
+ const SectionHeading = embedded ? "h3" : "h2";
674
+ const addButton = (
675
+ <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => setAdding(true)}>
676
+ Add by name
677
+ </Button>
678
+ );
679
+ const bulkBusy: boolean = bulk !== null;
680
+
681
+ return (
682
+ <>
683
+ {(working > 0 || pending) && <PluginProgressBar label={working > 0 ? "Working on plugins" : "Applying plugin changes"} />}
684
+ {embedded ? (
685
+ <p className="text-sm text-text-muted mb-5 max-w-3xl">
686
+ Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run
687
+ with full access to the server, so only add ones you trust.
688
+ </p>
689
+ ) : (
690
+ <>
691
+ <div className="flex items-center justify-between gap-4 mb-2">
692
+ <h1 className="text-xl font-bold uppercase tracking-wide">Plugins</h1>
693
+ {addButton}
694
+ </div>
695
+ <p className="text-sm text-text-muted mb-6 max-w-3xl">
696
+ Plugins add protocols and features to every server. Changes are applied by restarting the
697
+ servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so
698
+ only add ones you trust.
699
+ </p>
700
+ </>
701
+ )}
702
+
703
+ <label className="flex items-start gap-2 text-sm mb-5 max-w-3xl cursor-pointer">
704
+ <input
705
+ type="checkbox"
706
+ className="mt-0.5 h-4 w-4 shrink-0"
707
+ checked={allowPrerelease}
708
+ onChange={(e) => changePrerelease(e.target.checked)}
709
+ />
710
+ <span className="flex flex-col gap-0.5">
711
+ <span className="font-semibold">Allow pre-release versions</span>
712
+ <span className="text-xs text-text-muted">
713
+ Also offer beta and other pre-release builds, such as 1.0.0-beta.2, as updates and as versions to
714
+ install. They can be unstable. This is remembered in this browser.
715
+ </span>
716
+ </span>
717
+ </label>
718
+
719
+ {bulkFailure && (
720
+ <Alert>
721
+ <div>
722
+ {bulkFailure.title}
723
+ <ul className="list-disc pl-5 mt-1">
724
+ {bulkFailure.reasons.map((reason) => (
725
+ <li key={reason} className="break-words">
726
+ {reason}
727
+ </li>
728
+ ))}
729
+ </ul>
730
+ </div>
731
+ </Alert>
732
+ )}
733
+ {error && (
734
+ <Alert>
735
+ {error}
736
+ {retryEnable && (
737
+ <>
738
+ {" "}
739
+ <Button
740
+ type="button"
741
+ variant="text"
742
+ className="!w-auto !p-0"
743
+ onClick={() => void toggle(retryEnable)}
744
+ >
745
+ Try again
746
+ </Button>
747
+ </>
748
+ )}
749
+ </Alert>
750
+ )}
751
+ <RolloutBanner status={status} />
752
+ {statusStale && status && (
753
+ <p className="mb-4 text-xs text-text-muted">Couldn&apos;t refresh server status. Showing the last status reported.</p>
754
+ )}
755
+ {listStale && (
756
+ <p className="mb-4 text-xs text-text-muted">
757
+ Couldn&apos;t re-read the plugin list, so it may be out of date.{" "}
758
+ <Button type="button" variant="text" className="!w-auto !p-0 !text-xs" onClick={() => void reload()}>
759
+ Retry
760
+ </Button>
761
+ </p>
762
+ )}
763
+
764
+ <div className="flex items-center justify-between gap-4 mb-2">
765
+ <SectionHeading className="text-sm font-bold uppercase tracking-wide">Installed plugins</SectionHeading>
766
+ {embedded && addButton}
767
+ </div>
768
+ {loading ? (
769
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
770
+ ) : plugins.length === 0 && purges.length === 0 ? (
771
+ <p className="text-sm text-text-muted">No plugins installed.</p>
772
+ ) : (
773
+ <div className="overflow-x-auto">
774
+ {plugins.length > 0 && (
775
+ <div role="toolbar" aria-label="Actions for several plugins" className="flex flex-wrap items-center gap-2 py-2">
776
+ <label className="flex items-center gap-2 text-sm mr-2 cursor-pointer">
777
+ <input
778
+ type="checkbox"
779
+ className="h-4 w-4 shrink-0"
780
+ aria-label="Select all plugins"
781
+ checked={allSelected}
782
+ disabled={bulkBusy}
783
+ ref={(input) => {
784
+ if (input) {
785
+ input.indeterminate = selectedPlugins.length > 0 && !allSelected;
786
+ }
787
+ }}
788
+ onChange={(e) => setSelected(e.target.checked ? new Set(plugins.map((plugin) => plugin.uid)) : new Set())}
789
+ />
790
+ <span>{selectedPlugins.length > 0 ? `${selectedPlugins.length} selected` : "Select all"}</span>
791
+ </label>
792
+ <Button
793
+ type="button"
794
+ variant="secondary"
795
+ className="!w-auto"
796
+ aria-label="Upgrade selected plugins"
797
+ disabled={bulkBusy || selectedUpgrades.length === 0}
798
+ onClick={() =>
799
+ void runBulk("upgrade", selectedUpgrades, {
800
+ versions: new Map(selectedUpgrades.map((plugin) => [plugin.uid, updates.get(plugin.uid)!.latestVersion!])),
801
+ })
802
+ }
803
+ >
804
+ Upgrade{selectedUpgrades.length > 0 ? ` (${selectedUpgrades.length})` : ""}
805
+ </Button>
806
+ <Button
807
+ type="button"
808
+ variant="secondary"
809
+ className="!w-auto"
810
+ aria-label="Disable selected plugins"
811
+ disabled={bulkBusy || selectedEnabled.length === 0}
812
+ onClick={() => void runBulk("disable", selectedEnabled)}
813
+ >
814
+ Disable{selectedEnabled.length > 0 ? ` (${selectedEnabled.length})` : ""}
815
+ </Button>
816
+ <Button
817
+ type="button"
818
+ variant="secondary"
819
+ className="!w-auto"
820
+ aria-label="Uninstall selected plugins"
821
+ disabled={bulkBusy || selectedPlugins.length === 0}
822
+ onClick={() => setConfirmingBulkUninstall(selectedPlugins)}
823
+ >
824
+ Uninstall{selectedPlugins.length > 0 ? ` (${selectedPlugins.length})` : ""}
825
+ </Button>
826
+ <Button
827
+ type="button"
828
+ className="!w-auto ml-auto"
829
+ aria-label="Upgrade all plugins"
830
+ loading={bulk?.kind === "upgrade"}
831
+ disabled={bulkBusy}
832
+ onClick={() => void upgradeAll()}
833
+ >
834
+ Upgrade all
835
+ </Button>
836
+ {bulk && (
837
+ <span aria-live="polite" className="basis-full text-xs text-text-muted">
838
+ {BULK_WORDS[bulk.kind].doing} {bulk.current} ({bulk.done + 1} of {bulk.total})&hellip;
839
+ </span>
840
+ )}
841
+ </div>
842
+ )}
843
+ <table role="table" className="w-full text-sm border-collapse block lg:table">
844
+ <thead role="rowgroup" className="hidden lg:table-header-group">
845
+ <tr role="row">
846
+ <th role="columnheader" className={TH_CLASS}>
847
+ Plugin
848
+ </th>
849
+ <th role="columnheader" className={TH_CLASS}>
850
+ Version
851
+ </th>
852
+ <th role="columnheader" className={TH_CLASS}>
853
+ Status
854
+ </th>
855
+ <th role="columnheader" className={TH_CLASS}>
856
+ <span className="sr-only">Actions</span>
857
+ </th>
858
+ </tr>
859
+ </thead>
860
+ <tbody role="rowgroup" className="block lg:table-row-group">
861
+ {plugins.map((plugin) => {
862
+ const update: PluginUpdateInfo | undefined = updates.get(plugin.uid);
863
+ const latest: string | undefined = update?.updateAvailable ? update.latestVersion : undefined;
864
+ const busy: boolean = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
865
+ const requires: string[] = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
866
+ const requiredBy: string[] = plugins
867
+ .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
868
+ .map((other) => other.manifest.displayName);
869
+ return (
870
+ <tr key={plugin.uid} role="row" className={INSTALLED_ROW_CLASS}>
871
+ <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`}>
872
+ <div className="flex items-start gap-3">
873
+ <input
874
+ type="checkbox"
875
+ className="mt-1 h-4 w-4 shrink-0"
876
+ aria-label={`Select ${plugin.manifest.displayName}`}
877
+ checked={selected.has(plugin.uid)}
878
+ disabled={bulkBusy}
879
+ onChange={() => toggleSelected(plugin.uid)}
880
+ />
881
+ <div className="min-w-0">
882
+ <div className="font-semibold">{plugin.manifest.displayName}</div>
883
+ <div className="text-xs text-text-muted">{plugin.name}</div>
884
+ {plugin.manifest.description && (
885
+ <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
886
+ )}
887
+ {requires.length > 0 && <div className="text-xs text-text-muted mt-1">Requires: {requires.join(", ")}</div>}
888
+ {requiredBy.length > 0 && (
889
+ <div className="text-xs text-text-muted mt-1">Required by: {requiredBy.join(", ")}</div>
890
+ )}
891
+ </div>
892
+ </div>
893
+ </td>
894
+ <td role="cell" className={`${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`}>
895
+ <div>{plugin.packageVersion}</div>
896
+ {latest && <div className="mt-1 text-xs font-semibold text-primary-dark">Update available: {latest}</div>}
897
+ </td>
898
+ <td role="cell" className={`${INSTALLED_TD_CLASS} pt-1 pb-1`}>
899
+ <PluginStatusCell plugin={plugin} status={status} />
900
+ </td>
901
+ <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`}>
902
+ <div className="flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]">
903
+ <div className="flex flex-wrap gap-2 lg:justify-end">
904
+ {latest && (
905
+ <Button
906
+ type="button"
907
+ variant="secondary"
908
+ className="!w-auto"
909
+ aria-label={`Upgrade ${plugin.manifest.displayName} to ${latest}`}
910
+ disabled={busy}
911
+ onClick={() => void upgrade(plugin, latest)}
912
+ >
913
+ Upgrade
914
+ </Button>
915
+ )}
916
+ {(plugin.manifest.settings ?? []).length > 0 && (
917
+ <Button variant="secondary" type="button" className="!w-auto" onClick={() => setConfiguring(plugin)}>
918
+ Settings
919
+ </Button>
920
+ )}
921
+ <Button
922
+ variant={plugin.enabled ? "secondary" : "primary"}
923
+ type="button"
924
+ className="!w-auto min-w-[5.5rem]"
925
+ aria-label={`${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`}
926
+ disabled={busy}
927
+ onClick={() => void toggle(plugin)}
928
+ >
929
+ {plugin.enabled ? "Disable" : "Enable"}
930
+ </Button>
931
+ </div>
932
+ <div className="flex flex-wrap gap-x-4 lg:justify-end">
933
+ <Button variant="text" type="button" onClick={() => setUpgrading(plugin)}>
934
+ Change version
935
+ </Button>
936
+ <Button variant="text" type="button" onClick={() => setRemoving(plugin)}>
937
+ Uninstall
938
+ </Button>
939
+ </div>
940
+ </div>
941
+ </td>
942
+ </tr>
943
+ );
944
+ })}
945
+ {purges.map((purge) => (
946
+ <UninstalledPluginRow
947
+ key={purge.uid}
948
+ purge={purge}
949
+ busy={busyUids.has(purge.uid)}
950
+ onRetry={() => void retryPurge(purge)}
951
+ onInstall={() => void reinstall(purge)}
952
+ />
953
+ ))}
954
+ </tbody>
955
+ </table>
956
+ </div>
957
+ )}
958
+
959
+ <PluginBrowser
960
+ headingLevel={SectionHeading}
961
+ plugins={plugins}
962
+ prerelease={allowPrerelease}
963
+ onInstall={(result) => install(result.name, result.version, result.name)}
964
+ // The browser only offers an upgrade for a result it matched to one of these same `plugins`.
965
+ onUpgrade={(uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid)!, packageVersion)}
966
+ />
967
+
968
+ <AddPluginModal
969
+ open={adding}
970
+ prerelease={allowPrerelease}
971
+ onClose={() => setAdding(false)}
972
+ onAdd={async (name, packageVersion, displayName) => {
973
+ const problem = await install(name, packageVersion, displayName);
974
+ if (!problem) {
975
+ setAdding(false);
976
+ }
977
+ return problem;
978
+ }}
979
+ />
980
+ {upgrading && (
981
+ <ChangeVersionModal
982
+ plugin={upgrading}
983
+ prerelease={allowPrerelease}
984
+ onClose={() => setUpgrading(null)}
985
+ onSave={async (packageVersion) => {
986
+ const problem = await changeVersion(upgrading, packageVersion);
987
+ if (!problem) {
988
+ setUpgrading(null);
989
+ }
990
+ return problem;
991
+ }}
992
+ />
993
+ )}
994
+ {confirming && (
995
+ <ConfirmDependenciesModal
996
+ change={confirming}
997
+ displayNameOf={displayNameOf}
998
+ onClose={() => setConfirming(null)}
999
+ onDone={() => setConfirming(null)}
1000
+ />
1001
+ )}
1002
+ {confirmingBulkUninstall && (
1003
+ <BulkUninstallModal
1004
+ plugins={confirmingBulkUninstall}
1005
+ onClose={() => setConfirmingBulkUninstall(null)}
1006
+ onConfirm={(purgeData) => {
1007
+ const targets = confirmingBulkUninstall;
1008
+ setConfirmingBulkUninstall(null);
1009
+ void runBulk("uninstall", targets, { purgeData });
1010
+ }}
1011
+ />
1012
+ )}
1013
+ {configuring && (
1014
+ <SettingsModal
1015
+ plugin={configuring}
1016
+ onClose={() => setConfiguring(null)}
1017
+ onSaved={(plugin) => {
1018
+ setConfiguring(null);
1019
+ applied(plugin);
1020
+ }}
1021
+ />
1022
+ )}
1023
+ {removing && (
1024
+ <RemoveModal
1025
+ plugin={removing}
1026
+ onClose={() => setRemoving(null)}
1027
+ onRemoved={(result) => {
1028
+ const uid = removing.uid;
1029
+ const displayName = removing.manifest.displayName;
1030
+ setRemoving(null);
1031
+ setPlugins((prev) => prev.filter((plugin) => plugin.uid !== uid));
1032
+ if (result.purgeScheduled) {
1033
+ showScheduledPurge(result.purge);
1034
+ notify({
1035
+ kind: "info",
1036
+ title: `${displayName} uninstalled`,
1037
+ message: "Its data is deleted once every server has stopped running it. Adding the plugin again before then cancels the deletion.",
1038
+ });
1039
+ }
1040
+ watchRollout();
1041
+ }}
1042
+ />
1043
+ )}
1044
+ </>
1045
+ );
1046
+ }
1047
+
1048
+ const ALL_NAMESPACES = "";
1049
+
1050
+ /** Searches the configured namespaces' registries for plugin packages, with install and upgrade actions. */
1051
+ function PluginBrowser({
1052
+ headingLevel: Heading,
1053
+ plugins,
1054
+ prerelease,
1055
+ onInstall,
1056
+ onUpgrade,
1057
+ }: {
1058
+ headingLevel: "h2" | "h3";
1059
+ plugins: Plugin[];
1060
+ /** Whether prerelease versions count as a plugin's newest. */
1061
+ prerelease: boolean;
1062
+ /** Resolves why the plugin couldn't be installed, or `null`. */
1063
+ onInstall: (result: PluginSearchResult) => Promise<string | null>;
1064
+ onUpgrade: (uid: string, packageVersion: string) => Promise<void>;
1065
+ }) {
1066
+ const [namespaces, setNamespaces] = useState<PluginNamespace[]>([]);
1067
+ const [namespace, setNamespace] = useState(ALL_NAMESPACES);
1068
+ const [results, setResults] = useState<PluginSearchResult[] | null>(null);
1069
+ const [searching, setSearching] = useState(false);
1070
+ const [busyName, setBusyName] = useState<string | null>(null);
1071
+ const [error, setError] = useState<string | null>(null);
1072
+
1073
+ useEffect(() => {
1074
+ listPluginNamespaces()
1075
+ .then(setNamespaces)
1076
+ .catch(() => setNamespaces([]));
1077
+ }, []);
1078
+
1079
+ // What was found was found under the other setting, so it isn't shown as though it still held.
1080
+ useEffect(() => setResults(null), [prerelease]);
1081
+
1082
+ async function search(e: FormEvent) {
1083
+ e.preventDefault();
1084
+ setSearching(true);
1085
+ setError(null);
1086
+ try {
1087
+ setResults(await searchPlugins(namespace || undefined, { prerelease }));
1088
+ } catch (err) {
1089
+ setResults(null);
1090
+ setError(errorMessage(err, "Could not search for plugins."));
1091
+ } finally {
1092
+ setSearching(false);
1093
+ }
1094
+ }
1095
+
1096
+ // Keep results in step with installs and upgrades made anywhere on the page.
1097
+ const rows: PluginSearchResult[] = (results ?? []).map((result) => {
1098
+ const installed: Plugin | undefined = plugins.find((plugin) => plugin.name === result.name);
1099
+ return {
1100
+ ...result,
1101
+ installedUid: installed?.uid,
1102
+ installedVersion: installed?.packageVersion,
1103
+ updateAvailable: !!installed && installed.packageVersion !== result.version && result.updateAvailable,
1104
+ };
1105
+ });
1106
+
1107
+ async function install(result: PluginSearchResult) {
1108
+ setBusyName(result.name);
1109
+ setError(null);
1110
+ setError(await onInstall(result));
1111
+ setBusyName(null);
1112
+ }
1113
+
1114
+ async function upgrade(result: PluginSearchResult) {
1115
+ setBusyName(result.name);
1116
+ try {
1117
+ await onUpgrade(result.installedUid!, result.version);
1118
+ } finally {
1119
+ setBusyName(null);
1120
+ }
1121
+ }
1122
+
1123
+ return (
1124
+ <section aria-labelledby="plugin-browser-title" className="mt-8">
1125
+ <Heading id="plugin-browser-title" className="text-sm font-bold uppercase tracking-wide mb-2">
1126
+ Find plugins
1127
+ </Heading>
1128
+ <form onSubmit={(e) => void search(e)} className="flex flex-wrap gap-2 items-end mb-3">
1129
+ <label className="flex flex-col gap-1.5 text-sm">
1130
+ <span className="font-semibold">Namespace</span>
1131
+ <select aria-label="Namespace" className={INPUT_CLASS} value={namespace} onChange={(e) => setNamespace(e.target.value)}>
1132
+ <option value={ALL_NAMESPACES}>All namespaces</option>
1133
+ {namespaces.map((ns) => (
1134
+ <option key={ns.name} value={ns.name}>
1135
+ {ns.name}
1136
+ </option>
1137
+ ))}
1138
+ </select>
1139
+ </label>
1140
+ <Button type="submit" className="!w-auto" loading={searching} disabled={searching}>
1141
+ Search
1142
+ </Button>
1143
+ </form>
1144
+ {error && <Alert>{error}</Alert>}
1145
+ {results !== null &&
1146
+ (rows.length === 0 ? (
1147
+ <p className="text-sm text-text-muted">No plugins found.</p>
1148
+ ) : (
1149
+ <div className="overflow-x-auto">
1150
+ <table className="w-full text-sm border-collapse">
1151
+ <thead>
1152
+ <tr>
1153
+ <th className={TH_CLASS}>Package</th>
1154
+ <th className={TH_CLASS}>Latest version</th>
1155
+ <th className={TH_CLASS}>Status</th>
1156
+ <th className={TH_CLASS}>
1157
+ <span className="sr-only">Actions</span>
1158
+ </th>
1159
+ </tr>
1160
+ </thead>
1161
+ <tbody>
1162
+ {rows.map((result) => (
1163
+ <tr key={result.name}>
1164
+ <td className={TD_CLASS}>
1165
+ <div className="font-semibold">{result.name}</div>
1166
+ {result.description && <div className="text-xs text-text-muted max-w-md">{result.description}</div>}
1167
+ </td>
1168
+ <td className={TD_CLASS}>{result.version}</td>
1169
+ <td className={`${TD_CLASS} text-text-muted`}>
1170
+ {result.updateAvailable
1171
+ ? `Installed ${result.installedVersion} - update available`
1172
+ : result.installedUid
1173
+ ? `Installed ${result.installedVersion}`
1174
+ : result.allowed
1175
+ ? "Not installed"
1176
+ : "Not allowed on this server"}
1177
+ </td>
1178
+ <td className={`${TD_CLASS} text-right`}>
1179
+ {result.updateAvailable ? (
1180
+ <Button
1181
+ type="button"
1182
+ variant="secondary"
1183
+ className="!w-auto"
1184
+ aria-label={`Upgrade ${result.name} to ${result.version}`}
1185
+ disabled={busyName === result.name}
1186
+ onClick={() => void upgrade(result)}
1187
+ >
1188
+ Upgrade
1189
+ </Button>
1190
+ ) : (
1191
+ !result.installedUid &&
1192
+ result.allowed && (
1193
+ <Button
1194
+ type="button"
1195
+ className="!w-auto"
1196
+ aria-label={`Install ${result.name}`}
1197
+ loading={busyName === result.name}
1198
+ disabled={busyName === result.name}
1199
+ onClick={() => void install(result)}
1200
+ >
1201
+ Install
1202
+ </Button>
1203
+ )
1204
+ )}
1205
+ </td>
1206
+ </tr>
1207
+ ))}
1208
+ </tbody>
1209
+ </table>
1210
+ </div>
1211
+ ))}
1212
+ </section>
1213
+ );
1214
+ }
1215
+
1216
+ /** Name a server reports an error under when it isn't about any single plugin (e.g. the plugin set as a whole failed). */
1217
+ const SERVER_WIDE_ERROR_NAME = "*";
1218
+
1219
+ /**
1220
+ * Shown while any server copy hasn't applied the saved plugin set yet, when one is in safe mode, or when one reports a
1221
+ * server-wide plugin error - those match no plugin row, so without this they'd never be shown.
1222
+ */
1223
+ function RolloutBanner({ status }: { status: PluginStatus | null }) {
1224
+ if (!status || status.instances.length === 0) {
1225
+ return null;
1226
+ }
1227
+ const behind = status.instances.filter((instance) => instance.hash !== status.hash).length;
1228
+ const safeMode = status.instances.filter((instance) => instance.safeMode);
1229
+ const serverWideErrors = status.instances.flatMap((instance) =>
1230
+ instance.errors
1231
+ .filter((entry) => entry.name === SERVER_WIDE_ERROR_NAME)
1232
+ .map((entry) => `${instance.instance}: ${entry.message}`),
1233
+ );
1234
+ return (
1235
+ <>
1236
+ {serverWideErrors.length > 0 && (
1237
+ <Alert>
1238
+ Plugins couldn&rsquo;t be loaded:
1239
+ <ul className="list-disc pl-5 mt-1">
1240
+ {serverWideErrors.map((message) => (
1241
+ <li key={message} className="break-words">
1242
+ {message}
1243
+ </li>
1244
+ ))}
1245
+ </ul>
1246
+ </Alert>
1247
+ )}
1248
+ {behind > 0 && (
1249
+ <p role="status" className="mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
1250
+ Applying changes: {status.instances.length - behind} of {status.instances.length}{" "}
1251
+ {status.instances.length === 1 ? "server" : "servers"} updated. Servers restart one at a time.
1252
+ </p>
1253
+ )}
1254
+ {safeMode.length > 0 && (
1255
+ <Alert>
1256
+ {safeMode.map((instance) => instance.instance).join(", ")} started without any plugins because recent
1257
+ starts failed. Fix or disable the failing plugin, and the server will pick up the change.
1258
+ </Alert>
1259
+ )}
1260
+ </>
1261
+ );
1262
+ }
1263
+
1264
+ /** A plugin's enabled state and, for an enabled one, how many servers have loaded it and what went wrong. */
1265
+ function PluginStatusCell({ plugin, status }: { plugin: Plugin; status: PluginStatus | null }) {
1266
+ if (!plugin.enabled) {
1267
+ return <span className={`${BADGE_CLASS} bg-surface-alt text-text-muted`}>Disabled</span>;
1268
+ }
1269
+ return (
1270
+ <div className="flex flex-col items-start gap-1">
1271
+ <span className={`${BADGE_CLASS} bg-success text-white`}>Enabled</span>
1272
+ <PluginServers plugin={plugin} status={status} />
1273
+ </div>
1274
+ );
1275
+ }
1276
+
1277
+ function PluginServers({ plugin, status }: { plugin: Plugin; status: PluginStatus | null }) {
1278
+ if (!status || status.instances.length === 0) {
1279
+ return <span className="text-xs text-text-muted">Server status unknown</span>;
1280
+ }
1281
+ const current: PluginInstanceStatus[] = status.instances.filter((instance) => instance.hash === status.hash);
1282
+ const loaded = current.filter((instance) =>
1283
+ instance.loaded.some((entry) => entry.name === plugin.name && entry.version === plugin.packageVersion),
1284
+ ).length;
1285
+ const errors = current.flatMap((instance) =>
1286
+ instance.errors.filter((entry) => entry.name === plugin.name).map((entry) => `${instance.instance}: ${entry.message}`),
1287
+ );
1288
+ return (
1289
+ <div className="text-xs">
1290
+ <span className={loaded === status.instances.length ? "text-success font-medium" : "text-text-muted"}>
1291
+ Loaded on {loaded} of {status.instances.length} {status.instances.length === 1 ? "server" : "servers"}
1292
+ </span>
1293
+ {errors.map((message) => (
1294
+ <div key={message} className="text-danger mt-1 break-words">
1295
+ {message}
1296
+ </div>
1297
+ ))}
1298
+ </div>
1299
+ );
1300
+ }
1301
+
1302
+ /** The row of a plugin that was uninstalled but whose data deletion is still listed: what became of its data. */
1303
+ function UninstalledPluginRow({ purge, busy, onRetry, onInstall }: { purge: PluginPurgeInfo; busy: boolean; onRetry: () => void; onInstall: () => void }) {
1304
+ const failedSteps = purge.steps.filter((step) => !step.ok);
1305
+ return (
1306
+ <tr role="row" className={INSTALLED_ROW_CLASS}>
1307
+ <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`}>
1308
+ <div className="font-semibold">{purgeName(purge)}</div>
1309
+ <div className="text-xs text-text-muted">{purge.name}</div>
1310
+ </td>
1311
+ <td role="cell" className={`${INSTALLED_TD_CLASS} hidden lg:table-cell`} />
1312
+ <td role="cell" className={`${INSTALLED_TD_CLASS} pt-1 pb-3 basis-full lg:basis-auto`}>
1313
+ <div className="flex flex-col items-start gap-1 text-xs max-w-sm">
1314
+ <span className={`${BADGE_CLASS} bg-surface-alt text-text-muted`}>Uninstalled</span>
1315
+ {purge.state === "pending" && (
1316
+ <>
1317
+ <span>Uninstalled - data will be deleted after servers restart</span>
1318
+ {!!purge.serversRunning && (
1319
+ <span className="text-text-muted">
1320
+ {purge.serversRunning} of {purge.serversTotal} {purge.serversTotal === 1 ? "server" : "servers"} still running it
1321
+ </span>
1322
+ )}
1323
+ </>
1324
+ )}
1325
+ {purge.state === "running" && <span>Uninstalled - deleting its data now</span>}
1326
+ {purge.state === "done" && <span className="text-success font-medium">Data deleted{purge.completedAt ? ` ${formatDate(purge.completedAt)}` : ""}</span>}
1327
+ {purge.state === "failed" && (
1328
+ <>
1329
+ <span className="text-danger break-words">Data deletion failed: {purgeFailure(purge)}</span>
1330
+ {failedSteps.length > 0 && (
1331
+ <ul className="list-disc pl-4 text-text-muted break-words">
1332
+ {failedSteps.map((step) => (
1333
+ <li key={step.step}>
1334
+ {step.step}: {step.error ?? "failed"}
1335
+ </li>
1336
+ ))}
1337
+ </ul>
1338
+ )}
1339
+ </>
1340
+ )}
1341
+ </div>
1342
+ </td>
1343
+ <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-1 pb-3`}>
1344
+ <div className="flex gap-2 lg:justify-end">
1345
+ {purge.state === "failed" && (
1346
+ <Button
1347
+ type="button"
1348
+ variant="secondary"
1349
+ className="!w-auto"
1350
+ aria-label={`Retry deleting the data of ${purgeName(purge)}`}
1351
+ disabled={busy}
1352
+ loading={busy}
1353
+ onClick={onRetry}
1354
+ >
1355
+ Retry
1356
+ </Button>
1357
+ )}
1358
+ {/* A plugin can always be installed again. Adding it before its data is deleted cancels the deletion;
1359
+ while the deletion is running the server asks to wait until it has finished. */}
1360
+ <Button
1361
+ type="button"
1362
+ variant="secondary"
1363
+ className="!w-auto"
1364
+ aria-label={`Install ${purgeName(purge)}`}
1365
+ disabled={busy || purge.state === "running"}
1366
+ onClick={onInstall}
1367
+ >
1368
+ Install
1369
+ </Button>
1370
+ </div>
1371
+ </td>
1372
+ </tr>
1373
+ );
1374
+ }
1375
+
1376
+ function AddPluginModal({
1377
+ open,
1378
+ prerelease,
1379
+ onClose,
1380
+ onAdd,
1381
+ }: {
1382
+ open: boolean;
1383
+ /** Whether prerelease versions are listed, and count as the package's newest. */
1384
+ prerelease: boolean;
1385
+ onClose: () => void;
1386
+ /** Resolves why the plugin couldn't be added, or `null`. */
1387
+ onAdd: (name: string, packageVersion: string, displayName: string) => Promise<string | null>;
1388
+ }) {
1389
+ const [name, setName] = useState("");
1390
+ const [lookup, setLookup] = useState<PluginRegistryLookup | null>(null);
1391
+ const [selectedVersion, setSelectedVersion] = useState("");
1392
+ const [error, setError] = useState<string | null>(null);
1393
+ const [busy, setBusy] = useState(false);
1394
+ const [looking, setLooking] = useState(false);
1395
+ /** Identifies the latest lookup, so a slower earlier response can't replace what it found. */
1396
+ const lookupToken = useRef(0);
1397
+ /** Identifies each time the dialog is opened, so an add that finishes after it was closed can't show its result. */
1398
+ const openToken = useRef(0);
1399
+
1400
+ /** Forgets any lookup still in flight. */
1401
+ function invalidateLookup() {
1402
+ lookupToken.current++;
1403
+ setLooking(false);
1404
+ }
1405
+
1406
+ useEffect(() => {
1407
+ if (!open) {
1408
+ openToken.current++;
1409
+ invalidateLookup();
1410
+ setName("");
1411
+ setLookup(null);
1412
+ setSelectedVersion("");
1413
+ setError(null);
1414
+ setBusy(false);
1415
+ }
1416
+ }, [open]);
1417
+
1418
+ /** Looks up `packageName` at `packageVersion` (default: latest). `previous` is what a version change started
1419
+ * from - kept, with the failure in place of its manifest, when that version can't be read. */
1420
+ async function runLookup(packageName: string, packageVersion?: string, previous?: PluginRegistryLookup) {
1421
+ const token = ++lookupToken.current;
1422
+ setLooking(true);
1423
+ setError(null);
1424
+ try {
1425
+ const found = await lookupPluginPackage(packageName, packageVersion, { prerelease });
1426
+ if (token === lookupToken.current) {
1427
+ setLookup(found);
1428
+ setSelectedVersion(found.selected.version);
1429
+ }
1430
+ } catch (err) {
1431
+ if (token !== lookupToken.current) {
1432
+ return;
1433
+ }
1434
+ const message = errorMessage(err, "Could not look that package up.");
1435
+ if (previous && packageVersion) {
1436
+ setLookup({ ...previous, selected: { ...previous.selected, version: packageVersion, manifest: message } });
1437
+ } else {
1438
+ setLookup(null);
1439
+ setError(message);
1440
+ }
1441
+ } finally {
1442
+ if (token === lookupToken.current) {
1443
+ setLooking(false);
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ function find(e: FormEvent) {
1449
+ e.preventDefault();
1450
+ if (!name.trim()) {
1451
+ setError("Enter a package name.");
1452
+ return;
1453
+ }
1454
+ void runLookup(name.trim());
1455
+ }
1456
+
1457
+ function chooseVersion(version: string) {
1458
+ setSelectedVersion(version);
1459
+ // The manifest shown, and whether it's a loadable plugin, depend on the version.
1460
+ void runLookup(lookup!.package.name, version, lookup!);
1461
+ }
1462
+
1463
+ const manifest = lookup?.selected.manifest;
1464
+
1465
+ async function add() {
1466
+ const token: number = openToken.current;
1467
+ setBusy(true);
1468
+ setError(null);
1469
+ const name: string = lookup!.package.name;
1470
+ const problem = await onAdd(name, selectedVersion, typeof manifest === "object" ? manifest.displayName : name);
1471
+ if (token === openToken.current) {
1472
+ setError(problem);
1473
+ setBusy(false);
1474
+ }
1475
+ }
1476
+
1477
+ return (
1478
+ <Modal open={open} onClose={onClose} title="Add plugin">
1479
+ {error && <Alert>{error}</Alert>}
1480
+ <form onSubmit={find} className="flex gap-2 items-end mb-4">
1481
+ <label className="flex flex-col gap-1.5 text-sm flex-1">
1482
+ <span className="font-semibold">Package name</span>
1483
+ <input
1484
+ aria-label="Package name"
1485
+ className={INPUT_CLASS}
1486
+ value={name}
1487
+ placeholder="@rapidmx/activesync-plugin"
1488
+ onChange={(e) => {
1489
+ setName(e.target.value);
1490
+ invalidateLookup();
1491
+ setLookup(null);
1492
+ }}
1493
+ />
1494
+ </label>
1495
+ <Button type="submit" variant="secondary" className="!w-auto" loading={looking && !lookup} disabled={busy || looking}>
1496
+ Find
1497
+ </Button>
1498
+ </form>
1499
+ {lookup && (
1500
+ <div className="flex flex-col gap-3">
1501
+ {typeof manifest === "string" ? (
1502
+ <>
1503
+ <div className="text-sm font-semibold">{lookup.package.name}</div>
1504
+ <Alert>{manifest}</Alert>
1505
+ </>
1506
+ ) : (
1507
+ <div className="text-sm">
1508
+ <div className="font-semibold">{manifest?.displayName}</div>
1509
+ <div className="text-xs text-text-muted">{lookup.package.name}</div>
1510
+ {manifest?.description && <div className="text-text-muted">{manifest.description}</div>}
1511
+ </div>
1512
+ )}
1513
+ <label className="flex flex-col gap-1.5 text-sm">
1514
+ <span className="font-semibold">Version</span>
1515
+ <select aria-label="Version" className={INPUT_CLASS} value={selectedVersion} onChange={(e) => chooseVersion(e.target.value)}>
1516
+ {lookup.package.versions.map((version) => (
1517
+ <option key={version} value={version}>
1518
+ {version}
1519
+ {version === lookup.package.latest ? " (latest)" : ""}
1520
+ </option>
1521
+ ))}
1522
+ </select>
1523
+ </label>
1524
+ <div className="flex gap-2 justify-end">
1525
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1526
+ Cancel
1527
+ </Button>
1528
+ <Button
1529
+ type="button"
1530
+ className="!w-auto"
1531
+ loading={busy || looking}
1532
+ disabled={busy || looking || typeof manifest === "string"}
1533
+ onClick={() => void add()}
1534
+ >
1535
+ Add plugin
1536
+ </Button>
1537
+ </div>
1538
+ </div>
1539
+ )}
1540
+ </Modal>
1541
+ );
1542
+ }
1543
+
1544
+ function ChangeVersionModal({
1545
+ plugin,
1546
+ prerelease,
1547
+ onClose,
1548
+ onSave,
1549
+ }: {
1550
+ plugin: Plugin;
1551
+ /** Whether prerelease versions are listed, and count as the package's newest. */
1552
+ prerelease: boolean;
1553
+ onClose: () => void;
1554
+ /** Resolves why the version couldn't be changed, or `null`. */
1555
+ onSave: (packageVersion: string) => Promise<string | null>;
1556
+ }) {
1557
+ const [versions, setVersions] = useState<string[] | null>(null);
1558
+ const [latest, setLatest] = useState<string | undefined>();
1559
+ const [selected, setSelected] = useState(plugin.packageVersion);
1560
+ const [error, setError] = useState<string | null>(null);
1561
+ const [busy, setBusy] = useState(false);
1562
+
1563
+ useEffect(() => {
1564
+ lookupPluginPackage(plugin.name, undefined, { prerelease })
1565
+ .then((found) => {
1566
+ setVersions(found.package.versions);
1567
+ setLatest(found.package.latest);
1568
+ })
1569
+ .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
1570
+ }, [plugin.name, prerelease]);
1571
+
1572
+ async function save() {
1573
+ setBusy(true);
1574
+ setError(null);
1575
+ const problem = await onSave(selected);
1576
+ if (problem) {
1577
+ setError(problem);
1578
+ setBusy(false);
1579
+ }
1580
+ }
1581
+
1582
+ return (
1583
+ <Modal open onClose={onClose} title={`${plugin.manifest.displayName} version`}>
1584
+ {error && <Alert>{error}</Alert>}
1585
+ {versions === null ? (
1586
+ !error && <p className="text-sm text-text-muted">Loading&hellip;</p>
1587
+ ) : (
1588
+ <div className="flex flex-col gap-3">
1589
+ <label className="flex flex-col gap-1.5 text-sm">
1590
+ <span className="font-semibold">Version</span>
1591
+ <select aria-label="Version" className={INPUT_CLASS} value={selected} onChange={(e) => setSelected(e.target.value)}>
1592
+ {versions.map((version) => (
1593
+ <option key={version} value={version}>
1594
+ {version}
1595
+ {version === latest ? " (latest)" : ""}
1596
+ {version === plugin.packageVersion ? " (installed)" : ""}
1597
+ </option>
1598
+ ))}
1599
+ </select>
1600
+ </label>
1601
+ <div className="flex gap-2 justify-end">
1602
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1603
+ Cancel
1604
+ </Button>
1605
+ <Button
1606
+ type="button"
1607
+ className="!w-auto"
1608
+ loading={busy}
1609
+ disabled={busy || selected === plugin.packageVersion}
1610
+ onClick={() => void save()}
1611
+ >
1612
+ Save
1613
+ </Button>
1614
+ </div>
1615
+ </div>
1616
+ )}
1617
+ </Modal>
1618
+ );
1619
+ }
1620
+
1621
+ /** Lists the plugins a change also installs or enables, and makes the change once confirmed. */
1622
+ function ConfirmDependenciesModal({
1623
+ change,
1624
+ displayNameOf,
1625
+ onClose,
1626
+ onDone,
1627
+ }: {
1628
+ change: PendingChange;
1629
+ displayNameOf: (name: string) => string;
1630
+ onClose: () => void;
1631
+ onDone: () => void;
1632
+ }) {
1633
+ const [error, setError] = useState<string | null>(null);
1634
+ const [busy, setBusy] = useState(false);
1635
+ const { plan } = change;
1636
+
1637
+ async function proceed() {
1638
+ setBusy(true);
1639
+ setError(null);
1640
+ try {
1641
+ await change.apply(plan);
1642
+ onDone();
1643
+ } catch (err) {
1644
+ setError(errorMessage(err, change.failure));
1645
+ setBusy(false);
1646
+ }
1647
+ }
1648
+
1649
+ return (
1650
+ <Modal open onClose={onClose} title={`${change.displayName} requires other plugins`}>
1651
+ {error && <Alert>{error}</Alert>}
1652
+ <p className="text-sm mb-2">
1653
+ {change.displayName} {plan.plugin.version} needs these plugins, so they&apos;ll be changed too:
1654
+ </p>
1655
+ <ul className="text-sm list-disc pl-5 mb-4">
1656
+ {plan.install.map((dependency) => (
1657
+ <li key={dependency.name}>
1658
+ Install {dependency.manifest.displayName} {dependency.version}
1659
+ </li>
1660
+ ))}
1661
+ {plan.enable.map((name) => (
1662
+ <li key={name}>Enable {displayNameOf(name)}</li>
1663
+ ))}
1664
+ </ul>
1665
+ <div className="flex gap-2 justify-end">
1666
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1667
+ Cancel
1668
+ </Button>
1669
+ <Button type="button" className="!w-auto" loading={busy} disabled={busy} onClick={() => void proceed()}>
1670
+ Continue
1671
+ </Button>
1672
+ </div>
1673
+ </Modal>
1674
+ );
1675
+ }
1676
+
1677
+ /** Whether a required select has no value to fall back on, so the form has to pick (and save) one. */
1678
+ function needsSelection(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): boolean {
1679
+ return (
1680
+ definition.type === "select" &&
1681
+ !!definition.required &&
1682
+ saved === undefined &&
1683
+ definition.default === undefined &&
1684
+ (definition.options ?? []).length > 0
1685
+ );
1686
+ }
1687
+
1688
+ /** What a plugin's setting default writes for the host this console was reached at, like `https://<host>/meet`. */
1689
+ const HOST_PLACEHOLDER = "<host>";
1690
+
1691
+ /**
1692
+ * The value offered for a text setting whose default names the host (`https://<host>/meet`) and that has no real value
1693
+ * saved yet - nothing, an empty value, or the placeholder itself, which an older server stores when it installs the
1694
+ * plugin. It's the default with this console's host filled in, and is only stored once the form is saved.
1695
+ */
1696
+ function suggestedHost(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): string | undefined {
1697
+ if (typeof definition.default !== "string" || !definition.default.includes(HOST_PLACEHOLDER) || typeof window === "undefined") {
1698
+ return undefined;
1699
+ }
1700
+ const unset: boolean = saved === undefined || saved === "" || (typeof saved === "string" && saved.includes(HOST_PLACEHOLDER));
1701
+ return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
1702
+ }
1703
+
1704
+ /** What the deployment configures for a setting, as a form value: nothing for a secret, whose value the server keeps to itself. */
1705
+ function configuredValue(definition: PluginSettingDefinition, configured: PluginConfiguredSetting): PluginSettingValue | "" {
1706
+ if (definition.type === "boolean") {
1707
+ return configured.value === true;
1708
+ }
1709
+ return configured.value === undefined ? "" : String(configured.value);
1710
+ }
1711
+
1712
+ /** Whether a saved value is absent - nothing, or the empty value a plugin's own "" default is saved as. */
1713
+ function isUnsaved(saved: PluginSettingValue | undefined): boolean {
1714
+ return saved === undefined || saved === "";
1715
+ }
1716
+
1717
+ /** The suggestion for the host, unless the deployment configures the setting, which is offered instead. */
1718
+ function hostSuggestion(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined, configured: PluginConfiguredSetting | undefined): string | undefined {
1719
+ return configured ? undefined : suggestedHost(definition, saved);
1720
+ }
1721
+
1722
+ /** A setting's current form value: its saved value (which wins over the deployment's configuration); else what the
1723
+ * deployment configures; else the setting's own default - or, for a required select with none of those, its first
1724
+ * option. Kept as a string for text inputs. */
1725
+ function initialValue(
1726
+ definition: PluginSettingDefinition,
1727
+ saved: PluginSettingValue | undefined,
1728
+ configured?: PluginConfiguredSetting,
1729
+ ): PluginSettingValue | "" {
1730
+ if (configured && isUnsaved(saved)) {
1731
+ return configuredValue(definition, configured);
1732
+ }
1733
+ if (needsSelection(definition, saved)) {
1734
+ return definition.options![0].value;
1735
+ }
1736
+ const suggestion = hostSuggestion(definition, saved, configured);
1737
+ if (suggestion !== undefined) {
1738
+ return suggestion;
1739
+ }
1740
+ const value = saved ?? definition.default;
1741
+ if (definition.type === "boolean") {
1742
+ return value === true;
1743
+ }
1744
+ return value === undefined ? "" : String(value);
1745
+ }
1746
+
1747
+ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
1748
+ // Only opened from the Settings button, which is only shown for a plugin that declares settings.
1749
+ const definitions: PluginSettingDefinition[] = plugin.manifest.settings!;
1750
+ const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key], plugin.configured?.[d.key])])));
1751
+ const [values, setValues] = useState<Record<string, PluginSettingValue | "">>(initial.current);
1752
+ // Set by Reset: saving then drops every saved value, so the deployment's configuration (or the default) applies again.
1753
+ const [resetting, setResetting] = useState(false);
1754
+ const [error, setError] = useState<string | null>(null);
1755
+ const [busy, setBusy] = useState(false);
1756
+
1757
+ /** Puts every field back to what it shows with nothing saved. Nothing is stored until the form is saved. */
1758
+ function reset() {
1759
+ setValues(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, undefined, plugin.configured?.[d.key])])));
1760
+ setResetting(true);
1761
+ setError(null);
1762
+ }
1763
+
1764
+ async function save(e: FormEvent) {
1765
+ e.preventDefault();
1766
+ // The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
1767
+ // - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
1768
+ // A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
1769
+ // form shows them. What the deployment configures is never saved as a copy, which would outrank it when it changes
1770
+ // - a field that shows it is sent as nothing.
1771
+ const settings: Record<string, PluginSettingValue | null> = {};
1772
+ let changed = false;
1773
+ for (const definition of definitions) {
1774
+ const key: string = definition.key;
1775
+ const configured: PluginConfiguredSetting | undefined = plugin.configured?.[key];
1776
+ const saved: PluginSettingValue | undefined = plugin.settings[key];
1777
+ const value = values[key];
1778
+ if (resetting && value === initialValue(definition, undefined, configured)) {
1779
+ // What a fresh install saves: only a default naming this console's host has to be, for the plugin to work.
1780
+ // A saved value that already is the default shown stays, since dropping it changes nothing.
1781
+ settings[key] = !configured && saved !== undefined && String(saved) === String(value) ? saved : (hostSuggestion(definition, undefined, configured) ?? null);
1782
+ changed ||= settings[key] !== (saved ?? null);
1783
+ continue;
1784
+ }
1785
+ if (!resetting && value === initial.current[key] && !needsSelection(definition, saved) && hostSuggestion(definition, saved, configured) === undefined) {
1786
+ settings[key] = saved ?? null;
1787
+ continue;
1788
+ }
1789
+ changed = true;
1790
+ // A number input only ever reports a valid number or an empty string.
1791
+ settings[key] =
1792
+ value === "" || (configured && !configured.secret && String(value) === String(configured.value))
1793
+ ? null
1794
+ : definition.type === "number"
1795
+ ? Number(value)
1796
+ : value;
1797
+ }
1798
+ if (!changed) {
1799
+ onClose();
1800
+ return;
1801
+ }
1802
+ setBusy(true);
1803
+ setError(null);
1804
+ try {
1805
+ onSaved(await updatePlugin(plugin.uid, { version: plugin.version, settings }));
1806
+ } catch (err) {
1807
+ setError(errorMessage(err, "Could not save the settings."));
1808
+ } finally {
1809
+ setBusy(false);
1810
+ }
1811
+ }
1812
+
1813
+ return (
1814
+ <Modal open onClose={onClose} title={`${plugin.manifest.displayName} settings`}>
1815
+ {error && <Alert>{error}</Alert>}
1816
+ <form onSubmit={save} className="flex flex-col gap-5">
1817
+ {definitions.map((definition) => (
1818
+ <SettingField
1819
+ key={definition.key}
1820
+ definition={definition}
1821
+ value={values[definition.key]}
1822
+ configured={plugin.configured?.[definition.key]}
1823
+ onChange={(value) => setValues((prev) => ({ ...prev, [definition.key]: value }))}
1824
+ />
1825
+ ))}
1826
+ {/* Kept in view at the bottom of the dialog while a long list of settings scrolls. The dialog scrolls
1827
+ inside its padding, so the bar reaches past it to the dialog's edges. */}
1828
+ <div className="sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3">
1829
+ <p className="text-xs text-text-muted">
1830
+ {resetting
1831
+ ? "Every setting is back to what this deployment's configuration or the plugin's default gives. Save to apply that."
1832
+ : "Saving restarts the servers one at a time to apply the new settings."}
1833
+ </p>
1834
+ <div className="flex gap-2 justify-end">
1835
+ <Button type="button" variant="secondary" className="!w-auto mr-auto" onClick={reset} disabled={busy}>
1836
+ Reset
1837
+ </Button>
1838
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1839
+ Cancel
1840
+ </Button>
1841
+ <Button type="submit" className="!w-auto" loading={busy} disabled={busy}>
1842
+ Save
1843
+ </Button>
1844
+ </div>
1845
+ </div>
1846
+ </form>
1847
+ </Modal>
1848
+ );
1849
+ }
1850
+
1851
+ function SettingField({
1852
+ definition,
1853
+ value,
1854
+ configured,
1855
+ onChange,
1856
+ }: {
1857
+ definition: PluginSettingDefinition;
1858
+ value: PluginSettingValue | "";
1859
+ configured?: PluginConfiguredSetting;
1860
+ onChange: (value: PluginSettingValue | "") => void;
1861
+ }) {
1862
+ // What applies without a saved value; one saved here replaces it, and Reset goes back to it.
1863
+ const note = configured && (
1864
+ <span className="text-xs text-text-muted">
1865
+ This deployment&apos;s configuration provides {configured.secret ? "a value" : "the value shown"} unless one is saved here.
1866
+ </span>
1867
+ );
1868
+ // A secret's value never reaches the browser, so the field only says that it is set.
1869
+ const placeholder: string | undefined = configured?.secret ? "Set by this deployment" : undefined;
1870
+ const help = (
1871
+ <>
1872
+ {definition.help && <span className="text-xs text-text-muted">{definition.help}</span>}
1873
+ {note}
1874
+ </>
1875
+ );
1876
+ if (definition.type === "boolean") {
1877
+ // Laid out like the admin policy forms' checkboxes: the help sits under the label, beside the box.
1878
+ return (
1879
+ <label className="flex items-start gap-2 text-sm">
1880
+ <input type="checkbox" className="mt-1" checked={value === true} onChange={(e) => onChange(e.target.checked)} />
1881
+ <span className="flex flex-col gap-0.5">
1882
+ <span className="font-semibold">{definition.label}</span>
1883
+ {help}
1884
+ </span>
1885
+ </label>
1886
+ );
1887
+ }
1888
+ return (
1889
+ <label className="flex flex-col gap-1.5 text-sm">
1890
+ <span className="font-semibold">{definition.label}</span>
1891
+ {definition.type === "select" ? (
1892
+ <select aria-label={definition.label} className={INPUT_CLASS} value={String(value)} onChange={(e) => onChange(e.target.value)}>
1893
+ {!definition.required && <option value="">Default</option>}
1894
+ {(definition.options ?? []).map((option) => (
1895
+ <option key={option.value} value={option.value}>
1896
+ {option.label}
1897
+ </option>
1898
+ ))}
1899
+ </select>
1900
+ ) : (
1901
+ <input
1902
+ aria-label={definition.label}
1903
+ className={INPUT_CLASS}
1904
+ type={definition.type === "number" ? "number" : "text"}
1905
+ min={definition.min}
1906
+ max={definition.max}
1907
+ required={definition.required && !configured}
1908
+ placeholder={placeholder}
1909
+ value={String(value)}
1910
+ onChange={(e) => onChange(e.target.value)}
1911
+ />
1912
+ )}
1913
+ {help}
1914
+ </label>
1915
+ );
1916
+ }
1917
+
1918
+ /** The word typed to confirm deleting the data of several plugins at once, in place of a plugin's name. */
1919
+ const BULK_DELETE_WORD = "delete";
1920
+
1921
+ /** Asks for confirmation before several plugins are uninstalled at once, listing them, with the choice to delete the
1922
+ * data they stored too. Deleting data is permanent, so it also takes `BULK_DELETE_WORD` typed out. */
1923
+ function BulkUninstallModal({ plugins, onClose, onConfirm }: { plugins: Plugin[]; onClose: () => void; onConfirm: (purgeData: boolean) => void }) {
1924
+ const [purgeData, setPurgeData] = useState(false);
1925
+ const [typed, setTyped] = useState("");
1926
+ const helpId = useId();
1927
+ const single: boolean = plugins.length === 1;
1928
+ const confirmed: boolean = !purgeData || typed.trim().toLowerCase() === BULK_DELETE_WORD;
1929
+
1930
+ return (
1931
+ <Modal open onClose={onClose} title={`Uninstall ${pluralPlugins(plugins.length)}?`}>
1932
+ <form
1933
+ onSubmit={(event) => {
1934
+ event.preventDefault();
1935
+ if (confirmed) {
1936
+ onConfirm(purgeData);
1937
+ }
1938
+ }}
1939
+ >
1940
+ <p className="text-sm mb-2">The servers stop running {single ? "this plugin" : "these plugins"} after they restart:</p>
1941
+ <ul className="text-sm list-disc pl-5 mb-4">
1942
+ {plugins.map((plugin) => (
1943
+ <li key={plugin.uid}>{plugin.manifest.displayName}</li>
1944
+ ))}
1945
+ </ul>
1946
+ <p className="text-sm mb-4">
1947
+ {purgeData
1948
+ ? `Once every server has stopped running ${single ? "it" : "them"}, all the data ${single ? "it" : "they"} stored is deleted.`
1949
+ : `Data ${single ? "it" : "they"} stored stays in the database, and adding ${single ? "the plugin" : "a plugin"} again brings it back.`}
1950
+ </p>
1951
+ <div className="mb-4">
1952
+ <label className="flex items-start gap-2 text-sm font-semibold cursor-pointer">
1953
+ <input
1954
+ type="checkbox"
1955
+ className="mt-0.5 h-4 w-4 shrink-0 accent-danger"
1956
+ checked={purgeData}
1957
+ aria-describedby={helpId}
1958
+ onChange={(e) => setPurgeData(e.target.checked)}
1959
+ />
1960
+ <span>Also delete all data {single ? "this plugin" : "these plugins"} stored</span>
1961
+ </label>
1962
+ <div id={helpId} className="mt-2 pl-6 text-xs text-text-muted">
1963
+ <p>What is deleted, for each plugin:</p>
1964
+ <ul className="list-disc pl-5 mt-1 space-y-0.5">
1965
+ <li>the database collections and tables its features use, with everything in them</li>
1966
+ <li>its saved settings</li>
1967
+ <li>its downloaded package and cached pages on the servers</li>
1968
+ <li>whatever else it cleans up itself, such as files it stored or data kept outside the database</li>
1969
+ </ul>
1970
+ <p className="mt-2 text-danger font-semibold">This can&apos;t be undone.</p>
1971
+ </div>
1972
+ </div>
1973
+ {purgeData && (
1974
+ <>
1975
+ <p className="text-xs text-text-muted mb-3">
1976
+ Nothing is deleted while a server is still running a plugin. Adding a plugin again before its data is deleted cancels the deletion.
1977
+ </p>
1978
+ <label className="block text-sm mb-4">
1979
+ <span>
1980
+ Type <strong>{BULK_DELETE_WORD}</strong> to confirm
1981
+ </span>
1982
+ <input
1983
+ type="text"
1984
+ className={`${INPUT_CLASS} mt-1.5`}
1985
+ value={typed}
1986
+ autoComplete="off"
1987
+ spellCheck={false}
1988
+ onChange={(e) => setTyped(e.target.value)}
1989
+ />
1990
+ </label>
1991
+ </>
1992
+ )}
1993
+ <div className="flex gap-2 justify-end">
1994
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1995
+ Cancel
1996
+ </Button>
1997
+ <Button type="submit" className={purgeData ? DANGER_BUTTON_CLASS : "!w-auto"} disabled={!confirmed}>
1998
+ {purgeData ? `Uninstall ${pluralPlugins(plugins.length)} and delete data` : `Uninstall ${pluralPlugins(plugins.length)}`}
1999
+ </Button>
2000
+ </div>
2001
+ </form>
2002
+ </Modal>
2003
+ );
2004
+ }
2005
+
2006
+ function RemoveModal({ plugin, onClose, onRemoved }: { plugin: Plugin; onClose: () => void; onRemoved: (result: RemovePluginResult) => void }) {
2007
+ const [error, setError] = useState<string | null>(null);
2008
+ const [busy, setBusy] = useState(false);
2009
+ const [purgeData, setPurgeData] = useState(false);
2010
+ const [typed, setTyped] = useState("");
2011
+ const helpId = useId();
2012
+ const displayName = plugin.manifest.displayName;
2013
+ // Deleting data is destructive and permanent, so it also takes the plugin's name typed out.
2014
+ const confirmed = !purgeData || typed.trim().toLowerCase() === displayName.trim().toLowerCase();
2015
+
2016
+ async function remove(event: FormEvent) {
2017
+ event.preventDefault();
2018
+ setBusy(true);
2019
+ setError(null);
2020
+ try {
2021
+ onRemoved(await removePlugin(plugin.uid, { purgeData }));
2022
+ } catch (err) {
2023
+ setError(isElevationRequired(err) ? ELEVATION_MESSAGE : errorMessage(err, "Could not uninstall the plugin."));
2024
+ setBusy(false);
2025
+ }
2026
+ }
2027
+
2028
+ return (
2029
+ <Modal open onClose={onClose} title={`Uninstall ${displayName}?`}>
2030
+ <form onSubmit={(event) => void remove(event)}>
2031
+ {error && <Alert>{error}</Alert>}
2032
+ <p className="text-sm mb-4">
2033
+ {purgeData
2034
+ ? "The servers stop running this plugin after they restart, and then all the data it stored is deleted."
2035
+ : "The servers stop running this plugin after they restart. Data it stored stays in the database, and adding the plugin again brings it back."}
2036
+ </p>
2037
+ <div className="mb-4">
2038
+ <label className="flex items-start gap-2 text-sm font-semibold cursor-pointer">
2039
+ <input
2040
+ type="checkbox"
2041
+ className="mt-0.5 h-4 w-4 shrink-0 accent-danger"
2042
+ checked={purgeData}
2043
+ disabled={busy}
2044
+ aria-describedby={helpId}
2045
+ onChange={(e) => setPurgeData(e.target.checked)}
2046
+ />
2047
+ <span>Also delete all data this plugin stored</span>
2048
+ </label>
2049
+ <div id={helpId} className="mt-2 pl-6 text-xs text-text-muted">
2050
+ <p>What is deleted:</p>
2051
+ <ul className="list-disc pl-5 mt-1 space-y-0.5">
2052
+ <li>the database collections and tables the plugin&apos;s features use, with everything in them</li>
2053
+ <li>the plugin&apos;s saved settings</li>
2054
+ <li>its downloaded package and cached pages on the servers</li>
2055
+ <li>whatever else the plugin cleans up itself, such as files it stored or data kept outside the database</li>
2056
+ </ul>
2057
+ <p className="mt-2 text-danger font-semibold">This can&apos;t be undone.</p>
2058
+ </div>
2059
+ </div>
2060
+ {purgeData && (
2061
+ <>
2062
+ <p className="text-xs text-text-muted mb-3">
2063
+ Nothing is deleted while a server is still running the plugin. Once every server has restarted without it, usually
2064
+ within a few minutes, its data is deleted. Adding the plugin again before then cancels the deletion.
2065
+ </p>
2066
+ <label className="block text-sm mb-4">
2067
+ <span>
2068
+ Type <strong>{displayName}</strong> to confirm
2069
+ </span>
2070
+ <input
2071
+ type="text"
2072
+ className={`${INPUT_CLASS} mt-1.5`}
2073
+ value={typed}
2074
+ autoComplete="off"
2075
+ spellCheck={false}
2076
+ disabled={busy}
2077
+ onChange={(e) => setTyped(e.target.value)}
2078
+ />
2079
+ </label>
2080
+ </>
2081
+ )}
2082
+ <div className="flex gap-2 justify-end">
2083
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
2084
+ Cancel
2085
+ </Button>
2086
+ <Button type="submit" className={purgeData ? DANGER_BUTTON_CLASS : "!w-auto"} loading={busy} disabled={busy || !confirmed}>
2087
+ {purgeData ? "Uninstall and delete data" : "Uninstall"}
2088
+ </Button>
2089
+ </div>
2090
+ </form>
2091
+ </Modal>
2092
+ );
2093
+ }