@rapidmx/web-client 0.3.0 → 0.4.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 (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -0,0 +1,595 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useCallback, useEffect, useRef, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ addPlugin,
9
+ getPluginStatus,
10
+ listPlugins,
11
+ lookupPluginPackage,
12
+ Plugin,
13
+ PluginInstanceStatus,
14
+ PluginRegistryLookup,
15
+ PluginSettingDefinition,
16
+ PluginSettingValue,
17
+ PluginStatus,
18
+ removePlugin,
19
+ updatePlugin,
20
+ } from "@rapidmx/react-shared/admin/pluginsApi.js";
21
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
22
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
23
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
24
+
25
+ const INPUT_CLASS =
26
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
27
+
28
+ /** How often status is refreshed while server copies are still applying a change. */
29
+ const PENDING_POLL_MS = 5000;
30
+
31
+ function errorMessage(err: unknown, fallback: string): string {
32
+ return err instanceof ApiRequestError ? err.message : fallback;
33
+ }
34
+
35
+ /** Installed plugins with their rollout status, and every plugin action - shared by the Plugins page and the
36
+ * setup wizard. */
37
+ export default function PluginsManager() {
38
+ const [plugins, setPlugins] = useState<Plugin[]>([]);
39
+ const [status, setStatus] = useState<PluginStatus | null>(null);
40
+ const [loading, setLoading] = useState(true);
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [adding, setAdding] = useState(false);
43
+ const [upgrading, setUpgrading] = useState<Plugin | null>(null);
44
+ const [configuring, setConfiguring] = useState<Plugin | null>(null);
45
+ const [removing, setRemoving] = useState<Plugin | null>(null);
46
+ const [busyUid, setBusyUid] = useState<string | null>(null);
47
+
48
+ const refreshStatus = useCallback(() => {
49
+ // Status is advisory - a failure to read it shouldn't hide the plugin list.
50
+ return getPluginStatus()
51
+ .then(setStatus)
52
+ .catch(() => setStatus(null));
53
+ }, []);
54
+
55
+ useEffect(() => {
56
+ Promise.all([listPlugins(), refreshStatus()])
57
+ .then(([list]) => setPlugins(list))
58
+ .catch((err) => setError(errorMessage(err, "Could not load plugins.")))
59
+ .finally(() => setLoading(false));
60
+ }, [refreshStatus]);
61
+
62
+ const pending: boolean = !!status && status.instances.some((instance) => instance.hash !== status.hash);
63
+ useEffect(() => {
64
+ if (!pending) {
65
+ return;
66
+ }
67
+ const timer = setInterval(() => void refreshStatus(), PENDING_POLL_MS);
68
+ return () => clearInterval(timer);
69
+ }, [pending, refreshStatus]);
70
+
71
+ /** Applies a saved change locally and re-reads status, since saving starts a rollout. */
72
+ function applied(updated: Plugin) {
73
+ setPlugins((prev) => {
74
+ const exists = prev.some((plugin) => plugin.uid === updated.uid);
75
+ const next = exists ? prev.map((plugin) => (plugin.uid === updated.uid ? updated : plugin)) : [...prev, updated];
76
+ return next.sort((a, b) => a.name.localeCompare(b.name));
77
+ });
78
+ void refreshStatus();
79
+ }
80
+
81
+ async function toggle(plugin: Plugin) {
82
+ setBusyUid(plugin.uid);
83
+ setError(null);
84
+ try {
85
+ applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: !plugin.enabled }));
86
+ } catch (err) {
87
+ setError(errorMessage(err, `Could not ${plugin.enabled ? "disable" : "enable"} ${plugin.manifest.displayName}.`));
88
+ } finally {
89
+ setBusyUid(null);
90
+ }
91
+ }
92
+
93
+ return (
94
+ <>
95
+ <div className="flex items-center justify-between mb-2">
96
+ <h1 className="text-xl font-bold uppercase tracking-wide">Plugins</h1>
97
+ <Button type="button" className="!w-auto" onClick={() => setAdding(true)}>
98
+ + Add plugin
99
+ </Button>
100
+ </div>
101
+ <p className="text-sm text-text-muted mb-5 max-w-3xl">
102
+ Plugins add protocols and features to every server. Changes are applied by restarting the servers
103
+ one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones
104
+ you trust.
105
+ </p>
106
+
107
+ {error && <Alert>{error}</Alert>}
108
+ <RolloutBanner status={status} />
109
+
110
+ {loading ? (
111
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
112
+ ) : plugins.length === 0 ? (
113
+ <p className="text-sm text-text-muted">No plugins installed.</p>
114
+ ) : (
115
+ <div className="overflow-x-auto">
116
+ <table className="w-full text-sm border-collapse">
117
+ <thead>
118
+ <tr>
119
+ {["Plugin", "Version", "Status", "Enabled", ""].map((h) => (
120
+ <th
121
+ key={h}
122
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
123
+ >
124
+ {h}
125
+ </th>
126
+ ))}
127
+ </tr>
128
+ </thead>
129
+ <tbody>
130
+ {plugins.map((plugin) => (
131
+ <tr key={plugin.uid}>
132
+ <td className="py-2.5 px-2.5 border-b border-border align-top">
133
+ <div className="font-semibold">{plugin.manifest.displayName}</div>
134
+ <div className="text-xs text-text-muted">{plugin.name}</div>
135
+ {plugin.manifest.description && (
136
+ <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
137
+ )}
138
+ </td>
139
+ <td className="py-2.5 px-2.5 border-b border-border align-top">{plugin.packageVersion}</td>
140
+ <td className="py-2.5 px-2.5 border-b border-border align-top">
141
+ <PluginStatusCell plugin={plugin} status={status} />
142
+ </td>
143
+ <td className="py-2.5 px-2.5 border-b border-border align-top">
144
+ <label className="inline-flex items-center gap-2">
145
+ <input
146
+ type="checkbox"
147
+ aria-label={`Enable ${plugin.manifest.displayName}`}
148
+ checked={plugin.enabled}
149
+ disabled={busyUid === plugin.uid}
150
+ onChange={() => void toggle(plugin)}
151
+ />
152
+ </label>
153
+ </td>
154
+ <td className="py-2.5 px-2.5 border-b border-border align-top text-right whitespace-nowrap">
155
+ {(plugin.manifest.settings ?? []).length > 0 && (
156
+ <Button variant="text" type="button" className="!w-auto" onClick={() => setConfiguring(plugin)}>
157
+ Settings
158
+ </Button>
159
+ )}
160
+ <Button variant="text" type="button" className="!w-auto" onClick={() => setUpgrading(plugin)}>
161
+ Change version
162
+ </Button>
163
+ <Button variant="text" type="button" className="!w-auto" onClick={() => setRemoving(plugin)}>
164
+ Remove
165
+ </Button>
166
+ </td>
167
+ </tr>
168
+ ))}
169
+ </tbody>
170
+ </table>
171
+ </div>
172
+ )}
173
+
174
+ <AddPluginModal
175
+ open={adding}
176
+ onClose={() => setAdding(false)}
177
+ onAdded={(plugin) => {
178
+ setAdding(false);
179
+ applied(plugin);
180
+ }}
181
+ />
182
+ {upgrading && (
183
+ <ChangeVersionModal
184
+ plugin={upgrading}
185
+ onClose={() => setUpgrading(null)}
186
+ onSaved={(plugin) => {
187
+ setUpgrading(null);
188
+ applied(plugin);
189
+ }}
190
+ />
191
+ )}
192
+ {configuring && (
193
+ <SettingsModal
194
+ plugin={configuring}
195
+ onClose={() => setConfiguring(null)}
196
+ onSaved={(plugin) => {
197
+ setConfiguring(null);
198
+ applied(plugin);
199
+ }}
200
+ />
201
+ )}
202
+ {removing && (
203
+ <RemoveModal
204
+ plugin={removing}
205
+ onClose={() => setRemoving(null)}
206
+ onRemoved={() => {
207
+ const uid = removing.uid;
208
+ setRemoving(null);
209
+ setPlugins((prev) => prev.filter((plugin) => plugin.uid !== uid));
210
+ void refreshStatus();
211
+ }}
212
+ />
213
+ )}
214
+ </>
215
+ );
216
+ }
217
+
218
+ /** Shown while any server copy hasn't applied the saved plugin set yet, or when one is in safe mode. */
219
+ function RolloutBanner({ status }: { status: PluginStatus | null }) {
220
+ if (!status || status.instances.length === 0) {
221
+ return null;
222
+ }
223
+ const behind = status.instances.filter((instance) => instance.hash !== status.hash).length;
224
+ const safeMode = status.instances.filter((instance) => instance.safeMode);
225
+ return (
226
+ <>
227
+ {behind > 0 && (
228
+ <p role="status" className="mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
229
+ Applying changes: {status.instances.length - behind} of {status.instances.length}{" "}
230
+ {status.instances.length === 1 ? "server" : "servers"} updated. Servers restart one at a time.
231
+ </p>
232
+ )}
233
+ {safeMode.length > 0 && (
234
+ <Alert>
235
+ {safeMode.map((instance) => instance.instance).join(", ")} started without any plugins because recent
236
+ starts failed. Fix or disable the failing plugin, and the server will pick up the change.
237
+ </Alert>
238
+ )}
239
+ </>
240
+ );
241
+ }
242
+
243
+ function PluginStatusCell({ plugin, status }: { plugin: Plugin; status: PluginStatus | null }) {
244
+ if (!plugin.enabled) {
245
+ return <span className="text-text-muted">Disabled</span>;
246
+ }
247
+ if (!status || status.instances.length === 0) {
248
+ return <span className="text-text-muted">Unknown</span>;
249
+ }
250
+ const current: PluginInstanceStatus[] = status.instances.filter((instance) => instance.hash === status.hash);
251
+ const loaded = current.filter((instance) =>
252
+ instance.loaded.some((entry) => entry.name === plugin.name && entry.version === plugin.packageVersion),
253
+ ).length;
254
+ const errors = current.flatMap((instance) =>
255
+ instance.errors.filter((entry) => entry.name === plugin.name).map((entry) => `${instance.instance}: ${entry.message}`),
256
+ );
257
+ return (
258
+ <div>
259
+ <span className={loaded === status.instances.length ? "text-success font-medium" : "text-text-muted"}>
260
+ Loaded on {loaded} of {status.instances.length} {status.instances.length === 1 ? "server" : "servers"}
261
+ </span>
262
+ {errors.map((message) => (
263
+ <div key={message} className="text-xs text-danger mt-1">
264
+ {message}
265
+ </div>
266
+ ))}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function AddPluginModal({ open, onClose, onAdded }: { open: boolean; onClose: () => void; onAdded: (plugin: Plugin) => void }) {
272
+ const [name, setName] = useState("");
273
+ const [lookup, setLookup] = useState<PluginRegistryLookup | null>(null);
274
+ const [selectedVersion, setSelectedVersion] = useState("");
275
+ const [error, setError] = useState<string | null>(null);
276
+ const [busy, setBusy] = useState(false);
277
+
278
+ useEffect(() => {
279
+ if (!open) {
280
+ setName("");
281
+ setLookup(null);
282
+ setSelectedVersion("");
283
+ setError(null);
284
+ }
285
+ }, [open]);
286
+
287
+ async function find(e: FormEvent) {
288
+ e.preventDefault();
289
+ if (!name.trim()) {
290
+ setError("Enter a package name.");
291
+ return;
292
+ }
293
+ setBusy(true);
294
+ setError(null);
295
+ try {
296
+ const found = await lookupPluginPackage(name.trim());
297
+ setLookup(found);
298
+ setSelectedVersion(found.selected.version);
299
+ } catch (err) {
300
+ setLookup(null);
301
+ setError(errorMessage(err, "Could not look that package up."));
302
+ } finally {
303
+ setBusy(false);
304
+ }
305
+ }
306
+
307
+ async function add() {
308
+ setBusy(true);
309
+ setError(null);
310
+ try {
311
+ onAdded(await addPlugin(lookup!.package.name, selectedVersion));
312
+ } catch (err) {
313
+ setError(errorMessage(err, "Could not add the plugin."));
314
+ } finally {
315
+ setBusy(false);
316
+ }
317
+ }
318
+
319
+ const manifest = lookup?.selected.manifest;
320
+ return (
321
+ <Modal open={open} onClose={onClose} title="Add plugin">
322
+ {error && <Alert>{error}</Alert>}
323
+ <form onSubmit={find} className="flex gap-2 items-end mb-4">
324
+ <label className="flex flex-col gap-1.5 text-sm flex-1">
325
+ <span className="font-semibold">Package name</span>
326
+ <input
327
+ aria-label="Package name"
328
+ className={INPUT_CLASS}
329
+ value={name}
330
+ placeholder="@rapidmx/activesync"
331
+ onChange={(e) => {
332
+ setName(e.target.value);
333
+ setLookup(null);
334
+ }}
335
+ />
336
+ </label>
337
+ <Button type="submit" variant="secondary" className="!w-auto" loading={busy && !lookup} disabled={busy}>
338
+ Find
339
+ </Button>
340
+ </form>
341
+ {lookup && (
342
+ <div className="flex flex-col gap-3">
343
+ {typeof manifest === "string" ? (
344
+ <Alert>{manifest}</Alert>
345
+ ) : (
346
+ <div className="text-sm">
347
+ <div className="font-semibold">{manifest?.displayName}</div>
348
+ {manifest?.description && <div className="text-text-muted">{manifest.description}</div>}
349
+ </div>
350
+ )}
351
+ <label className="flex flex-col gap-1.5 text-sm">
352
+ <span className="font-semibold">Version</span>
353
+ <select aria-label="Version" className={INPUT_CLASS} value={selectedVersion} onChange={(e) => setSelectedVersion(e.target.value)}>
354
+ {lookup.package.versions.map((version) => (
355
+ <option key={version} value={version}>
356
+ {version}
357
+ {version === lookup.package.latest ? " (latest)" : ""}
358
+ </option>
359
+ ))}
360
+ </select>
361
+ </label>
362
+ <div className="flex gap-2 justify-end">
363
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
364
+ Cancel
365
+ </Button>
366
+ <Button type="button" className="!w-auto" loading={busy} disabled={busy || typeof manifest === "string"} onClick={() => void add()}>
367
+ Add plugin
368
+ </Button>
369
+ </div>
370
+ </div>
371
+ )}
372
+ </Modal>
373
+ );
374
+ }
375
+
376
+ function ChangeVersionModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
377
+ const [versions, setVersions] = useState<string[] | null>(null);
378
+ const [latest, setLatest] = useState<string | undefined>();
379
+ const [selected, setSelected] = useState(plugin.packageVersion);
380
+ const [error, setError] = useState<string | null>(null);
381
+ const [busy, setBusy] = useState(false);
382
+
383
+ useEffect(() => {
384
+ lookupPluginPackage(plugin.name)
385
+ .then((found) => {
386
+ setVersions(found.package.versions);
387
+ setLatest(found.package.latest);
388
+ })
389
+ .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
390
+ }, [plugin.name]);
391
+
392
+ async function save() {
393
+ setBusy(true);
394
+ setError(null);
395
+ try {
396
+ onSaved(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion: selected }));
397
+ } catch (err) {
398
+ setError(errorMessage(err, "Could not change the version."));
399
+ } finally {
400
+ setBusy(false);
401
+ }
402
+ }
403
+
404
+ return (
405
+ <Modal open onClose={onClose} title={`${plugin.manifest.displayName} version`}>
406
+ {error && <Alert>{error}</Alert>}
407
+ {versions === null ? (
408
+ !error && <p className="text-sm text-text-muted">Loading&hellip;</p>
409
+ ) : (
410
+ <div className="flex flex-col gap-3">
411
+ <label className="flex flex-col gap-1.5 text-sm">
412
+ <span className="font-semibold">Version</span>
413
+ <select aria-label="Version" className={INPUT_CLASS} value={selected} onChange={(e) => setSelected(e.target.value)}>
414
+ {versions.map((version) => (
415
+ <option key={version} value={version}>
416
+ {version}
417
+ {version === latest ? " (latest)" : ""}
418
+ {version === plugin.packageVersion ? " (installed)" : ""}
419
+ </option>
420
+ ))}
421
+ </select>
422
+ </label>
423
+ <div className="flex gap-2 justify-end">
424
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
425
+ Cancel
426
+ </Button>
427
+ <Button
428
+ type="button"
429
+ className="!w-auto"
430
+ loading={busy}
431
+ disabled={busy || selected === plugin.packageVersion}
432
+ onClick={() => void save()}
433
+ >
434
+ Save
435
+ </Button>
436
+ </div>
437
+ </div>
438
+ )}
439
+ </Modal>
440
+ );
441
+ }
442
+
443
+ /** A setting's current form value: its saved value, else its default. Kept as a string for text inputs. */
444
+ function initialValue(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): PluginSettingValue | "" {
445
+ const value = saved ?? definition.default;
446
+ if (definition.type === "boolean") {
447
+ return value === true;
448
+ }
449
+ return value === undefined ? "" : String(value);
450
+ }
451
+
452
+ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
453
+ const definitions: PluginSettingDefinition[] = plugin.manifest.settings ?? [];
454
+ const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
455
+ const [values, setValues] = useState<Record<string, PluginSettingValue | "">>(initial.current);
456
+ const [error, setError] = useState<string | null>(null);
457
+ const [busy, setBusy] = useState(false);
458
+
459
+ async function save(e: FormEvent) {
460
+ e.preventDefault();
461
+ const settings: Record<string, PluginSettingValue | null> = {};
462
+ for (const definition of definitions) {
463
+ const value = values[definition.key];
464
+ if (definition.type === "number") {
465
+ if (value === "") {
466
+ settings[definition.key] = null;
467
+ continue;
468
+ }
469
+ // A number input only ever reports a valid number or an empty string.
470
+ settings[definition.key] = Number(value);
471
+ } else {
472
+ settings[definition.key] = value === "" ? null : value;
473
+ }
474
+ }
475
+ setBusy(true);
476
+ setError(null);
477
+ try {
478
+ onSaved(await updatePlugin(plugin.uid, { version: plugin.version, settings }));
479
+ } catch (err) {
480
+ setError(errorMessage(err, "Could not save the settings."));
481
+ } finally {
482
+ setBusy(false);
483
+ }
484
+ }
485
+
486
+ return (
487
+ <Modal open onClose={onClose} title={`${plugin.manifest.displayName} settings`}>
488
+ {error && <Alert>{error}</Alert>}
489
+ <form onSubmit={save} className="flex flex-col gap-4">
490
+ {definitions.map((definition) => (
491
+ <SettingField
492
+ key={definition.key}
493
+ definition={definition}
494
+ value={values[definition.key]}
495
+ onChange={(value) => setValues((prev) => ({ ...prev, [definition.key]: value }))}
496
+ />
497
+ ))}
498
+ <p className="text-xs text-text-muted">Saving restarts the servers one at a time to apply the new settings.</p>
499
+ <div className="flex gap-2 justify-end">
500
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
501
+ Cancel
502
+ </Button>
503
+ <Button type="submit" className="!w-auto" loading={busy} disabled={busy}>
504
+ Save
505
+ </Button>
506
+ </div>
507
+ </form>
508
+ </Modal>
509
+ );
510
+ }
511
+
512
+ function SettingField({
513
+ definition,
514
+ value,
515
+ onChange,
516
+ }: {
517
+ definition: PluginSettingDefinition;
518
+ value: PluginSettingValue | "";
519
+ onChange: (value: PluginSettingValue | "") => void;
520
+ }) {
521
+ const help = definition.help && <span className="text-xs text-text-muted">{definition.help}</span>;
522
+ if (definition.type === "boolean") {
523
+ return (
524
+ <label className="flex flex-col gap-1 text-sm">
525
+ <span className="inline-flex items-center gap-2 font-semibold">
526
+ <input type="checkbox" checked={value === true} onChange={(e) => onChange(e.target.checked)} />
527
+ {definition.label}
528
+ </span>
529
+ {help}
530
+ </label>
531
+ );
532
+ }
533
+ return (
534
+ <label className="flex flex-col gap-1.5 text-sm">
535
+ <span className="font-semibold">{definition.label}</span>
536
+ {definition.type === "select" ? (
537
+ <select aria-label={definition.label} className={INPUT_CLASS} value={String(value)} onChange={(e) => onChange(e.target.value)}>
538
+ {!definition.required && <option value="">Default</option>}
539
+ {(definition.options ?? []).map((option) => (
540
+ <option key={option.value} value={option.value}>
541
+ {option.label}
542
+ </option>
543
+ ))}
544
+ </select>
545
+ ) : (
546
+ <input
547
+ aria-label={definition.label}
548
+ className={INPUT_CLASS}
549
+ type={definition.type === "number" ? "number" : "text"}
550
+ min={definition.min}
551
+ max={definition.max}
552
+ required={definition.required}
553
+ value={String(value)}
554
+ onChange={(e) => onChange(e.target.value)}
555
+ />
556
+ )}
557
+ {help}
558
+ </label>
559
+ );
560
+ }
561
+
562
+ function RemoveModal({ plugin, onClose, onRemoved }: { plugin: Plugin; onClose: () => void; onRemoved: () => void }) {
563
+ const [error, setError] = useState<string | null>(null);
564
+ const [busy, setBusy] = useState(false);
565
+
566
+ async function remove() {
567
+ setBusy(true);
568
+ setError(null);
569
+ try {
570
+ await removePlugin(plugin.uid);
571
+ onRemoved();
572
+ } catch (err) {
573
+ setError(errorMessage(err, "Could not remove the plugin."));
574
+ setBusy(false);
575
+ }
576
+ }
577
+
578
+ return (
579
+ <Modal open onClose={onClose} title={`Remove ${plugin.manifest.displayName}?`}>
580
+ {error && <Alert>{error}</Alert>}
581
+ <p className="text-sm mb-4">
582
+ The servers stop running this plugin after they restart. Data it stored stays in the database, and adding
583
+ the plugin again brings it back.
584
+ </p>
585
+ <div className="flex gap-2 justify-end">
586
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
587
+ Cancel
588
+ </Button>
589
+ <Button type="button" className="!w-auto" loading={busy} disabled={busy} onClick={() => void remove()}>
590
+ Remove
591
+ </Button>
592
+ </div>
593
+ </Modal>
594
+ );
595
+ }