@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,260 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { addPlugin, getPluginStatus, listPlugins, lookupPluginPackage, removePlugin, updatePlugin, } from "@rapidmx/react-shared/admin/pluginsApi.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
12
|
+
const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
13
|
+
/** How often status is refreshed while server copies are still applying a change. */
|
|
14
|
+
const PENDING_POLL_MS = 5000;
|
|
15
|
+
function errorMessage(err, fallback) {
|
|
16
|
+
return err instanceof ApiRequestError ? err.message : fallback;
|
|
17
|
+
}
|
|
18
|
+
/** Installed plugins with their rollout status, and every plugin action - shared by the Plugins page and the
|
|
19
|
+
* setup wizard. */
|
|
20
|
+
export default function PluginsManager() {
|
|
21
|
+
const [plugins, setPlugins] = useState([]);
|
|
22
|
+
const [status, setStatus] = useState(null);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
const [adding, setAdding] = useState(false);
|
|
26
|
+
const [upgrading, setUpgrading] = useState(null);
|
|
27
|
+
const [configuring, setConfiguring] = useState(null);
|
|
28
|
+
const [removing, setRemoving] = useState(null);
|
|
29
|
+
const [busyUid, setBusyUid] = useState(null);
|
|
30
|
+
const refreshStatus = useCallback(() => {
|
|
31
|
+
// Status is advisory - a failure to read it shouldn't hide the plugin list.
|
|
32
|
+
return getPluginStatus()
|
|
33
|
+
.then(setStatus)
|
|
34
|
+
.catch(() => setStatus(null));
|
|
35
|
+
}, []);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
Promise.all([listPlugins(), refreshStatus()])
|
|
38
|
+
.then(([list]) => setPlugins(list))
|
|
39
|
+
.catch((err) => setError(errorMessage(err, "Could not load plugins.")))
|
|
40
|
+
.finally(() => setLoading(false));
|
|
41
|
+
}, [refreshStatus]);
|
|
42
|
+
const pending = !!status && status.instances.some((instance) => instance.hash !== status.hash);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!pending) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const timer = setInterval(() => void refreshStatus(), PENDING_POLL_MS);
|
|
48
|
+
return () => clearInterval(timer);
|
|
49
|
+
}, [pending, refreshStatus]);
|
|
50
|
+
/** Applies a saved change locally and re-reads status, since saving starts a rollout. */
|
|
51
|
+
function applied(updated) {
|
|
52
|
+
setPlugins((prev) => {
|
|
53
|
+
const exists = prev.some((plugin) => plugin.uid === updated.uid);
|
|
54
|
+
const next = exists ? prev.map((plugin) => (plugin.uid === updated.uid ? updated : plugin)) : [...prev, updated];
|
|
55
|
+
return next.sort((a, b) => a.name.localeCompare(b.name));
|
|
56
|
+
});
|
|
57
|
+
void refreshStatus();
|
|
58
|
+
}
|
|
59
|
+
async function toggle(plugin) {
|
|
60
|
+
setBusyUid(plugin.uid);
|
|
61
|
+
setError(null);
|
|
62
|
+
try {
|
|
63
|
+
applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: !plugin.enabled }));
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
setError(errorMessage(err, `Could not ${plugin.enabled ? "disable" : "enable"} ${plugin.manifest.displayName}.`));
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
setBusyUid(null);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Plugins" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => setAdding(true), children: "+ Add plugin" })] }), _jsx("p", { className: "text-sm text-text-muted mb-5 max-w-3xl", children: "Plugins add protocols and features to every server. Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." }), error && _jsx(Alert, { children: error }), _jsx(RolloutBanner, { status: status }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : plugins.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No plugins installed." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Plugin", "Version", "Status", "Enabled", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: plugins.map((plugin) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border align-top", children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description }))] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-top", children: plugin.packageVersion }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-top", children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-top", children: _jsx("label", { className: "inline-flex items-center gap-2", children: _jsx("input", { type: "checkbox", "aria-label": `Enable ${plugin.manifest.displayName}`, checked: plugin.enabled, disabled: busyUid === plugin.uid, onChange: () => void toggle(plugin) }) }) }), _jsxs("td", { className: "py-2.5 px-2.5 border-b border-border align-top text-right whitespace-nowrap", children: [(plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "text", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: "text", type: "button", className: "!w-auto", onClick: () => setUpgrading(plugin), children: "Change version" }), _jsx(Button, { variant: "text", type: "button", className: "!w-auto", onClick: () => setRemoving(plugin), children: "Remove" })] })] }, plugin.uid))) })] }) })), _jsx(AddPluginModal, { open: adding, onClose: () => setAdding(false), onAdded: (plugin) => {
|
|
73
|
+
setAdding(false);
|
|
74
|
+
applied(plugin);
|
|
75
|
+
} }), upgrading && (_jsx(ChangeVersionModal, { plugin: upgrading, onClose: () => setUpgrading(null), onSaved: (plugin) => {
|
|
76
|
+
setUpgrading(null);
|
|
77
|
+
applied(plugin);
|
|
78
|
+
} })), configuring && (_jsx(SettingsModal, { plugin: configuring, onClose: () => setConfiguring(null), onSaved: (plugin) => {
|
|
79
|
+
setConfiguring(null);
|
|
80
|
+
applied(plugin);
|
|
81
|
+
} })), removing && (_jsx(RemoveModal, { plugin: removing, onClose: () => setRemoving(null), onRemoved: () => {
|
|
82
|
+
const uid = removing.uid;
|
|
83
|
+
setRemoving(null);
|
|
84
|
+
setPlugins((prev) => prev.filter((plugin) => plugin.uid !== uid));
|
|
85
|
+
void refreshStatus();
|
|
86
|
+
} }))] }));
|
|
87
|
+
}
|
|
88
|
+
/** Shown while any server copy hasn't applied the saved plugin set yet, or when one is in safe mode. */
|
|
89
|
+
function RolloutBanner({ status }) {
|
|
90
|
+
if (!status || status.instances.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const behind = status.instances.filter((instance) => instance.hash !== status.hash).length;
|
|
94
|
+
const safeMode = status.instances.filter((instance) => instance.safeMode);
|
|
95
|
+
return (_jsxs(_Fragment, { children: [behind > 0 && (_jsxs("p", { role: "status", className: "mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: ["Applying changes: ", status.instances.length - behind, " of ", status.instances.length, " ", status.instances.length === 1 ? "server" : "servers", " updated. Servers restart one at a time."] })), safeMode.length > 0 && (_jsxs(Alert, { children: [safeMode.map((instance) => instance.instance).join(", "), " started without any plugins because recent starts failed. Fix or disable the failing plugin, and the server will pick up the change."] }))] }));
|
|
96
|
+
}
|
|
97
|
+
function PluginStatusCell({ plugin, status }) {
|
|
98
|
+
if (!plugin.enabled) {
|
|
99
|
+
return _jsx("span", { className: "text-text-muted", children: "Disabled" });
|
|
100
|
+
}
|
|
101
|
+
if (!status || status.instances.length === 0) {
|
|
102
|
+
return _jsx("span", { className: "text-text-muted", children: "Unknown" });
|
|
103
|
+
}
|
|
104
|
+
const current = status.instances.filter((instance) => instance.hash === status.hash);
|
|
105
|
+
const loaded = current.filter((instance) => instance.loaded.some((entry) => entry.name === plugin.name && entry.version === plugin.packageVersion)).length;
|
|
106
|
+
const errors = current.flatMap((instance) => instance.errors.filter((entry) => entry.name === plugin.name).map((entry) => `${instance.instance}: ${entry.message}`));
|
|
107
|
+
return (_jsxs("div", { children: [_jsxs("span", { className: loaded === status.instances.length ? "text-success font-medium" : "text-text-muted", children: ["Loaded on ", loaded, " of ", status.instances.length, " ", status.instances.length === 1 ? "server" : "servers"] }), errors.map((message) => (_jsx("div", { className: "text-xs text-danger mt-1", children: message }, message)))] }));
|
|
108
|
+
}
|
|
109
|
+
function AddPluginModal({ open, onClose, onAdded }) {
|
|
110
|
+
const [name, setName] = useState("");
|
|
111
|
+
const [lookup, setLookup] = useState(null);
|
|
112
|
+
const [selectedVersion, setSelectedVersion] = useState("");
|
|
113
|
+
const [error, setError] = useState(null);
|
|
114
|
+
const [busy, setBusy] = useState(false);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!open) {
|
|
117
|
+
setName("");
|
|
118
|
+
setLookup(null);
|
|
119
|
+
setSelectedVersion("");
|
|
120
|
+
setError(null);
|
|
121
|
+
}
|
|
122
|
+
}, [open]);
|
|
123
|
+
async function find(e) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
if (!name.trim()) {
|
|
126
|
+
setError("Enter a package name.");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
setBusy(true);
|
|
130
|
+
setError(null);
|
|
131
|
+
try {
|
|
132
|
+
const found = await lookupPluginPackage(name.trim());
|
|
133
|
+
setLookup(found);
|
|
134
|
+
setSelectedVersion(found.selected.version);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
setLookup(null);
|
|
138
|
+
setError(errorMessage(err, "Could not look that package up."));
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
setBusy(false);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function add() {
|
|
145
|
+
setBusy(true);
|
|
146
|
+
setError(null);
|
|
147
|
+
try {
|
|
148
|
+
onAdded(await addPlugin(lookup.package.name, selectedVersion));
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
setError(errorMessage(err, "Could not add the plugin."));
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
setBusy(false);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const manifest = lookup?.selected.manifest;
|
|
158
|
+
return (_jsxs(Modal, { open: open, onClose: onClose, title: "Add plugin", children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: find, className: "flex gap-2 items-end mb-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm flex-1", children: [_jsx("span", { className: "font-semibold", children: "Package name" }), _jsx("input", { "aria-label": "Package name", className: INPUT_CLASS, value: name, placeholder: "@rapidmx/activesync", onChange: (e) => {
|
|
159
|
+
setName(e.target.value);
|
|
160
|
+
setLookup(null);
|
|
161
|
+
} })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: busy && !lookup, disabled: busy, children: "Find" })] }), lookup && (_jsxs("div", { className: "flex flex-col gap-3", children: [typeof manifest === "string" ? (_jsx(Alert, { children: manifest })) : (_jsxs("div", { className: "text-sm", children: [_jsx("div", { className: "font-semibold", children: manifest?.displayName }), manifest?.description && _jsx("div", { className: "text-text-muted", children: manifest.description })] })), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selectedVersion, onChange: (e) => setSelectedVersion(e.target.value), children: lookup.package.versions.map((version) => (_jsxs("option", { value: version, children: [version, version === lookup.package.latest ? " (latest)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || typeof manifest === "string", onClick: () => void add(), children: "Add plugin" })] })] }))] }));
|
|
162
|
+
}
|
|
163
|
+
function ChangeVersionModal({ plugin, onClose, onSaved }) {
|
|
164
|
+
const [versions, setVersions] = useState(null);
|
|
165
|
+
const [latest, setLatest] = useState();
|
|
166
|
+
const [selected, setSelected] = useState(plugin.packageVersion);
|
|
167
|
+
const [error, setError] = useState(null);
|
|
168
|
+
const [busy, setBusy] = useState(false);
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
lookupPluginPackage(plugin.name)
|
|
171
|
+
.then((found) => {
|
|
172
|
+
setVersions(found.package.versions);
|
|
173
|
+
setLatest(found.package.latest);
|
|
174
|
+
})
|
|
175
|
+
.catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
|
|
176
|
+
}, [plugin.name]);
|
|
177
|
+
async function save() {
|
|
178
|
+
setBusy(true);
|
|
179
|
+
setError(null);
|
|
180
|
+
try {
|
|
181
|
+
onSaved(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion: selected }));
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
setError(errorMessage(err, "Could not change the version."));
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
setBusy(false);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} version`, children: [error && _jsx(Alert, { children: error }), versions === null ? (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selected, onChange: (e) => setSelected(e.target.value), children: versions.map((version) => (_jsxs("option", { value: version, children: [version, version === latest ? " (latest)" : "", version === plugin.packageVersion ? " (installed)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || selected === plugin.packageVersion, onClick: () => void save(), children: "Save" })] })] }))] }));
|
|
191
|
+
}
|
|
192
|
+
/** A setting's current form value: its saved value, else its default. Kept as a string for text inputs. */
|
|
193
|
+
function initialValue(definition, saved) {
|
|
194
|
+
const value = saved ?? definition.default;
|
|
195
|
+
if (definition.type === "boolean") {
|
|
196
|
+
return value === true;
|
|
197
|
+
}
|
|
198
|
+
return value === undefined ? "" : String(value);
|
|
199
|
+
}
|
|
200
|
+
function SettingsModal({ plugin, onClose, onSaved }) {
|
|
201
|
+
const definitions = plugin.manifest.settings ?? [];
|
|
202
|
+
const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
|
|
203
|
+
const [values, setValues] = useState(initial.current);
|
|
204
|
+
const [error, setError] = useState(null);
|
|
205
|
+
const [busy, setBusy] = useState(false);
|
|
206
|
+
async function save(e) {
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
const settings = {};
|
|
209
|
+
for (const definition of definitions) {
|
|
210
|
+
const value = values[definition.key];
|
|
211
|
+
if (definition.type === "number") {
|
|
212
|
+
if (value === "") {
|
|
213
|
+
settings[definition.key] = null;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// A number input only ever reports a valid number or an empty string.
|
|
217
|
+
settings[definition.key] = Number(value);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
settings[definition.key] = value === "" ? null : value;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
setBusy(true);
|
|
224
|
+
setError(null);
|
|
225
|
+
try {
|
|
226
|
+
onSaved(await updatePlugin(plugin.uid, { version: plugin.version, settings }));
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
setError(errorMessage(err, "Could not save the settings."));
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
setBusy(false);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} settings`, children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: save, className: "flex flex-col gap-4", children: [definitions.map((definition) => (_jsx(SettingField, { definition: definition, value: values[definition.key], onChange: (value) => setValues((prev) => ({ ...prev, [definition.key]: value })) }, definition.key))), _jsx("p", { className: "text-xs text-text-muted", children: "Saving restarts the servers one at a time to apply the new settings." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, children: "Save" })] })] })] }));
|
|
236
|
+
}
|
|
237
|
+
function SettingField({ definition, value, onChange, }) {
|
|
238
|
+
const help = definition.help && _jsx("span", { className: "text-xs text-text-muted", children: definition.help });
|
|
239
|
+
if (definition.type === "boolean") {
|
|
240
|
+
return (_jsxs("label", { className: "flex flex-col gap-1 text-sm", children: [_jsxs("span", { className: "inline-flex items-center gap-2 font-semibold", children: [_jsx("input", { type: "checkbox", checked: value === true, onChange: (e) => onChange(e.target.checked) }), definition.label] }), help] }));
|
|
241
|
+
}
|
|
242
|
+
return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: definition.label }), definition.type === "select" ? (_jsxs("select", { "aria-label": definition.label, className: INPUT_CLASS, value: String(value), onChange: (e) => onChange(e.target.value), children: [!definition.required && _jsx("option", { value: "", children: "Default" }), (definition.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })) : (_jsx("input", { "aria-label": definition.label, className: INPUT_CLASS, type: definition.type === "number" ? "number" : "text", min: definition.min, max: definition.max, required: definition.required, value: String(value), onChange: (e) => onChange(e.target.value) })), help] }));
|
|
243
|
+
}
|
|
244
|
+
function RemoveModal({ plugin, onClose, onRemoved }) {
|
|
245
|
+
const [error, setError] = useState(null);
|
|
246
|
+
const [busy, setBusy] = useState(false);
|
|
247
|
+
async function remove() {
|
|
248
|
+
setBusy(true);
|
|
249
|
+
setError(null);
|
|
250
|
+
try {
|
|
251
|
+
await removePlugin(plugin.uid);
|
|
252
|
+
onRemoved();
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
setError(errorMessage(err, "Could not remove the plugin."));
|
|
256
|
+
setBusy(false);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return (_jsxs(Modal, { open: true, onClose: onClose, title: `Remove ${plugin.manifest.displayName}?`, children: [error && _jsx(Alert, { children: error }), _jsx("p", { className: "text-sm mb-4", children: "The servers stop running this plugin after they restart. Data it stored stays in the database, and adding the plugin again brings it back." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy, onClick: () => void remove(), children: "Remove" })] })] }));
|
|
260
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { MIN_AUDIT_LOG_RETENTION_DAYS, updateRetentionPolicy } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
12
|
+
/** The retention policy editor, shared by the Retention Policy page and the setup wizard. */
|
|
13
|
+
export default function RetentionPolicyForm({ policy, onChange }) {
|
|
14
|
+
const [messageRetentionDays, setMessageRetentionDays] = useState(policy.messageRetentionDays?.toString() ?? "");
|
|
15
|
+
const [auditLogRetentionDays, setAuditLogRetentionDays] = useState(policy.auditLogRetentionDays?.toString() ?? "");
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const [saving, setSaving] = useState(false);
|
|
18
|
+
const [saved, setSaved] = useState(false);
|
|
19
|
+
async function handleSubmit(e) {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
setError(null);
|
|
22
|
+
setSaved(false);
|
|
23
|
+
setSaving(true);
|
|
24
|
+
try {
|
|
25
|
+
const patch = {};
|
|
26
|
+
if (messageRetentionDays.trim() !== "") {
|
|
27
|
+
patch.messageRetentionDays = Number(messageRetentionDays);
|
|
28
|
+
}
|
|
29
|
+
if (auditLogRetentionDays.trim() !== "") {
|
|
30
|
+
patch.auditLogRetentionDays = Number(auditLogRetentionDays);
|
|
31
|
+
}
|
|
32
|
+
const updated = await updateRetentionPolicy(patch);
|
|
33
|
+
onChange(updated);
|
|
34
|
+
setSaved(true);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save the retention policy.");
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
setSaving(false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Retention Policy" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Automatically and permanently deletes mail/audit-log entries older than the configured age. Leave a field blank for no automatic purge. This does not apply to a mailbox that is a custodian on an active legal hold \u2014 a held message is skipped and retried on a later run once the hold is lifted." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Message retention (days)" }), _jsx("input", { "aria-label": "Message retention (days)", type: "number", min: 1, className: INPUT_CLASS, value: messageRetentionDays, onChange: (e) => setMessageRetentionDays(e.target.value), placeholder: "No automatic purge" }), _jsx("span", { className: "text-xs text-text-muted", children: "Applies to any message, in any folder, regardless of age." })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Audit log retention (days)" }), _jsx("input", { "aria-label": "Audit log retention (days)", type: "number", min: MIN_AUDIT_LOG_RETENTION_DAYS, className: INPUT_CLASS, value: auditLogRetentionDays, onChange: (e) => setAuditLogRetentionDays(e.target.value), placeholder: "Keep forever" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Cannot be set below ", MIN_AUDIT_LOG_RETENTION_DAYS, " days. Never applies to the tamper-evident escrow audit chain."] })] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { createEscrowScope, listEscrowScopes } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
|
|
9
|
+
import { generateEscrowKeyPair } from "@rapidmx/react-shared/crypto/escrowKeys.js";
|
|
10
|
+
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
11
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
13
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
14
|
+
import EscrowScopeKeyAndHoldersFields, { emptyEscrowScopeKeyAndHoldersValue, } from "../escrowScopes/EscrowScopeKeyAndHoldersFields.js";
|
|
15
|
+
const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
16
|
+
/** Offers `text` to the browser as a file download. */
|
|
17
|
+
export function downloadTextFile(filename, text, type = "application/x-pem-file") {
|
|
18
|
+
const url = URL.createObjectURL(new Blob([text], { type }));
|
|
19
|
+
const link = document.createElement("a");
|
|
20
|
+
link.href = url;
|
|
21
|
+
link.download = filename;
|
|
22
|
+
document.body.appendChild(link);
|
|
23
|
+
link.click();
|
|
24
|
+
link.remove();
|
|
25
|
+
URL.revokeObjectURL(url);
|
|
26
|
+
}
|
|
27
|
+
function fileSafe(name) {
|
|
28
|
+
return name.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "escrow";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The setup wizard's escrow step. Escrow lets a separate group of holders recover a mailbox's encrypted mail (for
|
|
32
|
+
* example for legal discovery) using a key this server never has. It's optional, and the administrator can either
|
|
33
|
+
* generate the key pair here (downloading the private key, which never leaves the browser), paste in an existing
|
|
34
|
+
* certificate, or go without escrow.
|
|
35
|
+
*/
|
|
36
|
+
export default function EscrowSetupStep() {
|
|
37
|
+
const [scopes, setScopes] = useState(null);
|
|
38
|
+
const [mode, setMode] = useState("none");
|
|
39
|
+
const [name, setName] = useState("Escrow");
|
|
40
|
+
const [validYears, setValidYears] = useState(5);
|
|
41
|
+
const [generated, setGenerated] = useState(null);
|
|
42
|
+
const [savedPrivateKey, setSavedPrivateKey] = useState(false);
|
|
43
|
+
const [keyAndHolders, setKeyAndHolders] = useState(emptyEscrowScopeKeyAndHoldersValue());
|
|
44
|
+
const [error, setError] = useState(null);
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
listEscrowScopes({ limit: 100 })
|
|
48
|
+
.then(setScopes)
|
|
49
|
+
.catch(() => setScopes([]));
|
|
50
|
+
}, []);
|
|
51
|
+
function chooseMode(next) {
|
|
52
|
+
setMode(next);
|
|
53
|
+
setError(null);
|
|
54
|
+
setGenerated(null);
|
|
55
|
+
setSavedPrivateKey(false);
|
|
56
|
+
setKeyAndHolders(emptyEscrowScopeKeyAndHoldersValue());
|
|
57
|
+
}
|
|
58
|
+
async function generate() {
|
|
59
|
+
if (!name.trim()) {
|
|
60
|
+
setError("Give the escrow scope a name first.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setBusy(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
try {
|
|
66
|
+
const keys = await generateEscrowKeyPair({ name: name.trim(), validDays: validYears * 365 });
|
|
67
|
+
setGenerated(keys);
|
|
68
|
+
setKeyAndHolders({
|
|
69
|
+
...emptyEscrowScopeKeyAndHoldersValue(),
|
|
70
|
+
publicKey: keys.publicKey.publicKey,
|
|
71
|
+
keyType: keys.publicKey.type,
|
|
72
|
+
fingerprint: keys.publicKey.fingerprint,
|
|
73
|
+
notBefore: toDatetimeLocal(new Date(keys.publicKey.notBefore).toISOString()),
|
|
74
|
+
notAfter: toDatetimeLocal(new Date(keys.publicKey.notAfter).toISOString()),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
setError("This browser couldn't generate the escrow keys.");
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
setBusy(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function create(e) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setError(null);
|
|
87
|
+
if (!name.trim()) {
|
|
88
|
+
setError("A name is required.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!keyAndHolders.publicKey.trim() || !keyAndHolders.keyType.trim() || !keyAndHolders.fingerprint.trim()) {
|
|
92
|
+
setError("The public key, its type, and its fingerprint are all required.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (keyAndHolders.holderUserUids.length === 0) {
|
|
96
|
+
setError("At least one holder is required.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (keyAndHolders.requiredHolders < 1 || keyAndHolders.requiredHolders > keyAndHolders.holderUserUids.length) {
|
|
100
|
+
setError("Required holders must be between 1 and the number of holders.");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setBusy(true);
|
|
104
|
+
try {
|
|
105
|
+
const created = await createEscrowScope({
|
|
106
|
+
name: name.trim(),
|
|
107
|
+
publicKey: {
|
|
108
|
+
publicKey: keyAndHolders.publicKey.trim(),
|
|
109
|
+
type: keyAndHolders.keyType.trim(),
|
|
110
|
+
fingerprint: keyAndHolders.fingerprint.trim(),
|
|
111
|
+
notBefore: new Date(keyAndHolders.notBefore).getTime(),
|
|
112
|
+
notAfter: new Date(keyAndHolders.notAfter).getTime(),
|
|
113
|
+
},
|
|
114
|
+
holderUserUids: keyAndHolders.holderUserUids,
|
|
115
|
+
requiredHolders: keyAndHolders.requiredHolders,
|
|
116
|
+
notifySubjectOnAccess: keyAndHolders.notifySubjectOnAccess,
|
|
117
|
+
});
|
|
118
|
+
setScopes((prev) => [...(prev ?? []), created]);
|
|
119
|
+
chooseMode("none");
|
|
120
|
+
setName("Escrow");
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create the escrow scope.");
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
setBusy(false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const scopeForm = (_jsxs("form", { onSubmit: create, className: "flex flex-col gap-5", children: [_jsx(EscrowScopeKeyAndHoldersFields, { value: keyAndHolders, onChange: setKeyAndHolders, disabled: mode === "generate" && busy }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: busy, disabled: busy, className: "!w-auto", children: "Create escrow scope" }) })] }));
|
|
130
|
+
return (_jsxs("div", { className: "flex flex-col gap-5 max-w-3xl", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "Escrow" }), _jsx("p", { className: "text-sm text-text-muted", children: "Escrow lets a group of trusted holders - for example your legal or compliance team - recover a mailbox\u2019s encrypted mail when they all agree to. It\u2019s optional. Without it, mail that someone encrypts can\u2019t be recovered if they lose their keys, by anyone." })] }), scopes && scopes.length > 0 && (_jsxs("div", { role: "status", className: "text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: ["Escrow scopes set up: ", scopes.map((scope) => scope.name).join(", "), "."] })), error && _jsx(Alert, { children: error }), _jsxs("fieldset", { className: "flex flex-col gap-2 text-sm", children: [_jsx("legend", { className: "font-semibold mb-1", children: "How do you want to set up escrow?" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "escrow-mode", checked: mode === "generate", onChange: () => chooseMode("generate") }), "Generate new escrow keys in this browser"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "escrow-mode", checked: mode === "existing", onChange: () => chooseMode("existing") }), "Use a certificate I already have"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "escrow-mode", checked: mode === "none", onChange: () => chooseMode("none") }), scopes && scopes.length > 0 ? "Don't add another escrow scope" : "Don't use escrow"] })] }), mode !== "none" && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Escrow scope name", htmlFor: "escrowName", children: _jsx("input", { id: "escrowName", type: "text", className: INPUT_CLASS, value: name, disabled: !!generated, onChange: (e) => setName(e.target.value) }) }), mode === "generate" && !generated && (_jsxs(_Fragment, { children: [_jsx(FormField, { label: "Keys valid for", htmlFor: "escrowValidYears", children: _jsx("select", { id: "escrowValidYears", className: INPUT_CLASS, value: validYears, onChange: (e) => setValidYears(Number(e.target.value)), children: [1, 2, 5, 10].map((years) => (_jsxs("option", { value: years, children: [years, " ", years === 1 ? "year" : "years"] }, years))) }) }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy, onClick: () => void generate(), children: "Generate keys" })] })), mode === "generate" && generated && (_jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsx(Alert, { children: "Download the private key now and give it to your escrow holders. It isn\u2019t stored anywhere else - if it\u2019s lost, no escrowed mail can ever be recovered." }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { type: "button", className: "!w-auto", onClick: () => downloadTextFile(`${fileSafe(name)}-private-key.pem`, generated.privateKeyPem), children: "Download private key" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => downloadTextFile(`${fileSafe(name)}-certificate.pem`, generated.certificatePem), children: "Download certificate" })] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "checkbox", checked: savedPrivateKey, onChange: (e) => setSavedPrivateKey(e.target.checked) }), "I\u2019ve saved the private key somewhere safe"] })] }))] })), mode === "existing" && scopeForm, mode === "generate" && generated && savedPrivateKey && scopeForm] }));
|
|
131
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { getRetentionPolicy } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
|
|
9
|
+
import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
|
|
10
|
+
import { createDomain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
11
|
+
import { completeSetup, getSetupStatus, saveSetupStep } from "@rapidmx/react-shared/admin/setupApi.js";
|
|
12
|
+
import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
13
|
+
import { getEncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
14
|
+
import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
15
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
16
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
17
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
18
|
+
import BrandingForm from "../settings/BrandingForm.js";
|
|
19
|
+
import DomainDnsSetup from "../settings/DomainDnsSetup.js";
|
|
20
|
+
import EncryptionPolicyForm, { isEncryptionEnabled } from "../settings/EncryptionPolicyForm.js";
|
|
21
|
+
import LoadedSettingsForm from "../settings/LoadedSettingsForm.js";
|
|
22
|
+
import MailboxCreateForm from "../settings/MailboxCreateForm.js";
|
|
23
|
+
import MailboxPolicyForm from "../settings/MailboxPolicyForm.js";
|
|
24
|
+
import PluginsManager from "../settings/PluginsManager.js";
|
|
25
|
+
import RetentionPolicyForm from "../settings/RetentionPolicyForm.js";
|
|
26
|
+
import EscrowSetupStep from "./EscrowSetupStep.js";
|
|
27
|
+
const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
28
|
+
export const SETUP_STEPS = [
|
|
29
|
+
{ id: "plugins", title: "Plugins", intro: "Choose which protocols and features this server runs. The recommended plugins are already installed." },
|
|
30
|
+
{ id: "domain", title: "Domain", intro: "Add the domain this server receives mail for, then publish the DNS records below." },
|
|
31
|
+
{ id: "settings", title: "Server settings", intro: "Set the rules for encryption, how long mail is kept, and new mailboxes." },
|
|
32
|
+
{ id: "escrow", title: "Escrow", intro: "Decide whether encrypted mail can be recovered by a trusted group of holders." },
|
|
33
|
+
{ id: "branding", title: "Branding", intro: "Make the sign-in page and apps look like your organization." },
|
|
34
|
+
{ id: "mailboxes", title: "Mailboxes", intro: "Create the first mailboxes, starting with your own." },
|
|
35
|
+
];
|
|
36
|
+
function isStepId(value) {
|
|
37
|
+
return SETUP_STEPS.some((step) => step.id === value);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The first-run setup wizard. Each step saves as the administrator works through it; the wizard itself only records
|
|
41
|
+
* which step they're on (so leaving and coming back resumes there) and, at the end, that setup is finished. Every step
|
|
42
|
+
* except the domain can be passed over and configured later from the admin console.
|
|
43
|
+
*/
|
|
44
|
+
export default function SetupWizard({ userUid }) {
|
|
45
|
+
const [step, setStep] = useState(null);
|
|
46
|
+
const [domains, setDomains] = useState([]);
|
|
47
|
+
const [encryption, setEncryption] = useState(null);
|
|
48
|
+
const [finishing, setFinishing] = useState(false);
|
|
49
|
+
const [error, setError] = useState(null);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
void Promise.all([getSetupStatus().catch(() => undefined), listDomains({ limit: 25 }).catch(() => [])]).then(([status, list]) => {
|
|
52
|
+
setDomains(list);
|
|
53
|
+
setStep(isStepId(status?.currentStep) ? status.currentStep : "plugins");
|
|
54
|
+
});
|
|
55
|
+
}, []);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (step === "escrow" || step === "settings") {
|
|
58
|
+
getEncryptionPolicy()
|
|
59
|
+
.then(setEncryption)
|
|
60
|
+
.catch(() => setEncryption(null));
|
|
61
|
+
}
|
|
62
|
+
}, [step]);
|
|
63
|
+
if (step === null) {
|
|
64
|
+
return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
|
|
65
|
+
}
|
|
66
|
+
const index = SETUP_STEPS.findIndex((s) => s.id === step);
|
|
67
|
+
const current = SETUP_STEPS[index];
|
|
68
|
+
const last = index === SETUP_STEPS.length - 1;
|
|
69
|
+
function goTo(next) {
|
|
70
|
+
setError(null);
|
|
71
|
+
setStep(next);
|
|
72
|
+
// Progress is a convenience for resuming - failing to record it shouldn't stop the administrator.
|
|
73
|
+
void saveSetupStep(next).catch(() => undefined);
|
|
74
|
+
window.scrollTo?.(0, 0);
|
|
75
|
+
}
|
|
76
|
+
async function finish() {
|
|
77
|
+
setFinishing(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
try {
|
|
80
|
+
await completeSetup();
|
|
81
|
+
window.location.href = "/admin";
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not finish setup.");
|
|
85
|
+
setFinishing(false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const blocked = step === "domain" && domains.length === 0;
|
|
89
|
+
return (_jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Set up your server" }), _jsx("p", { className: "text-sm text-text-muted", children: "You can change any of these settings later from the admin console." })] }), _jsx("ol", { "aria-label": "Setup steps", className: "flex flex-wrap gap-2", children: SETUP_STEPS.map((s, i) => (_jsx("li", { children: _jsxs("button", { type: "button", "aria-current": s.id === step ? "step" : undefined,
|
|
90
|
+
// Steps after the domain need somewhere to put mail first.
|
|
91
|
+
disabled: i > 1 && domains.length === 0, onClick: () => goTo(s.id), className: [
|
|
92
|
+
"text-sm py-1.5 px-3 rounded-pill border disabled:opacity-50",
|
|
93
|
+
s.id === step
|
|
94
|
+
? "bg-primary/10 border-primary text-primary-dark font-semibold"
|
|
95
|
+
: i < index
|
|
96
|
+
? "border-border text-text"
|
|
97
|
+
: "border-border text-text-muted",
|
|
98
|
+
].join(" "), children: [i + 1, ". ", s.title, i < index && _jsx("span", { className: "sr-only", children: " (done)" })] }) }, s.id))) }), _jsxs("section", { "aria-labelledby": "setup-step-title", className: "flex flex-col gap-5", children: [_jsxs("div", { children: [_jsxs("h2", { id: "setup-step-title", className: "text-lg font-bold", children: ["Step ", index + 1, " of ", SETUP_STEPS.length, ": ", current.title] }), _jsx("p", { className: "text-sm text-text-muted", children: current.intro })] }), step === "plugins" && _jsx(PluginsManager, {}), step === "domain" && _jsx(DomainStep, { domains: domains, onCreated: (domain) => setDomains((prev) => [...prev, domain]) }), step === "settings" && (_jsxs("div", { className: "flex flex-col gap-10", children: [_jsx(LoadedSettingsForm, { load: getEncryptionPolicy, loadErrorMessage: "Could not load the encryption policy.", children: (policy, onChange) => (_jsx(EncryptionPolicyForm, { policy: policy, onChange: (updated) => {
|
|
99
|
+
onChange(updated);
|
|
100
|
+
setEncryption(updated);
|
|
101
|
+
} })) }), _jsx(LoadedSettingsForm, { load: getRetentionPolicy, loadErrorMessage: "Could not load the retention policy.", children: (policy, onChange) => _jsx(RetentionPolicyForm, { policy: policy, onChange: onChange }) }), _jsx(LoadedSettingsForm, { load: getMailboxPolicy, loadErrorMessage: "Could not load the mailbox policy.", children: (policy, onChange) => _jsx(MailboxPolicyForm, { policy: policy, onChange: onChange }) })] })), step === "escrow" &&
|
|
102
|
+
(encryption && !isEncryptionEnabled(encryption) ? (_jsx("p", { role: "status", className: "text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: "End-to-end encryption is turned off, so escrow isn\u2019t needed. You can set it up later if you turn encryption on." })) : (_jsx(EscrowSetupStep, {}))), step === "branding" && (_jsx(LoadedSettingsForm, { load: getBranding, loadErrorMessage: "Could not load branding.", children: (branding, onChange) => _jsx(BrandingForm, { branding: branding, onChange: onChange }) })), step === "mailboxes" && _jsx(MailboxesStep, { userUid: userUid, domain: domains.find((d) => d.verified)?.name ?? domains[0]?.name })] }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex items-center gap-3 border-t border-border pt-5", children: [index > 0 && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => goTo(SETUP_STEPS[index - 1].id), children: "Back" })), _jsx("div", { className: "flex-1" }), blocked && _jsx("span", { className: "text-sm text-text-muted", children: "Add a domain to continue." }), last ? (_jsx(Button, { type: "button", className: "!w-auto", loading: finishing, disabled: finishing, onClick: () => void finish(), children: "Finish setup" })) : (_jsx(Button, { type: "button", className: "!w-auto", disabled: blocked, onClick: () => goTo(SETUP_STEPS[index + 1].id), children: "Continue" }))] })] }));
|
|
103
|
+
}
|
|
104
|
+
function DomainStep({ domains, onCreated }) {
|
|
105
|
+
const [name, setName] = useState("");
|
|
106
|
+
const [error, setError] = useState(null);
|
|
107
|
+
const [saving, setSaving] = useState(false);
|
|
108
|
+
async function handleSubmit(e) {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
setError(null);
|
|
111
|
+
if (!name.trim()) {
|
|
112
|
+
setError("A domain name is required.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
setSaving(true);
|
|
116
|
+
try {
|
|
117
|
+
onCreated(await createDomain({ name: name.trim() }));
|
|
118
|
+
setName("");
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not add the domain.");
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
setSaving(false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return (_jsxs("div", { className: "flex flex-col gap-5 max-w-3xl", children: [domains.map((domain) => (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("h3", { className: "text-base font-bold", children: domain.name }), _jsx(DomainDnsSetup, { uid: domain.uid })] }, domain.uid))), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: domains.length === 0 ? "Domain name" : "Add another domain", htmlFor: "setupDomainName", children: _jsx("input", { id: "setupDomainName", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Add domain" })] })] }));
|
|
128
|
+
}
|
|
129
|
+
function MailboxesStep({ userUid, domain }) {
|
|
130
|
+
const [mailboxes, setMailboxes] = useState([]);
|
|
131
|
+
const [formKey, setFormKey] = useState(0);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
listMailboxes({ limit: 100 })
|
|
134
|
+
.then(setMailboxes)
|
|
135
|
+
.catch(() => undefined);
|
|
136
|
+
}, []);
|
|
137
|
+
const hasOwn = mailboxes.some((mailbox) => mailbox.ownerUserUid === userUid);
|
|
138
|
+
return (_jsxs("div", { className: "flex flex-col gap-5 max-w-xl", children: [mailboxes.length > 0 && (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-bold uppercase tracking-wide mb-2", children: "Mailboxes created" }), _jsx("ul", { className: "text-sm flex flex-col gap-1", children: mailboxes.map((mailbox) => (_jsxs("li", { children: [mailbox.displayName, " <", mailbox.primarySmtpAddress, ">", mailbox.ownerUserUid === userUid && _jsx("span", { className: "text-text-muted", children: " (yours)" })] }, mailbox.uid))) })] })), _jsx("h3", { className: "text-base font-bold", children: hasOwn ? "Add another mailbox" : "Your mailbox" }), _jsx(MailboxCreateForm, { submitLabel: "Create mailbox", defaults: hasOwn ? { domain } : { localPart: "admin", domain, displayName: "Administrator", ownerUserUid: userUid }, onCreated: (mailbox) => {
|
|
139
|
+
setMailboxes((prev) => [...prev, mailbox]);
|
|
140
|
+
setFormKey((k) => k + 1);
|
|
141
|
+
} }, formKey)] }));
|
|
142
|
+
}
|