@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.
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- 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…</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…</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
|
+
}
|