@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,105 @@
|
|
|
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 { useRef, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { deleteBrandingIcon, deleteBrandingLogo, deleteBrandingStylesheet, updateBranding, uploadBrandingIcon, uploadBrandingLogo, uploadBrandingStylesheet, } from "@rapidmx/react-shared/branding/brandingApi.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
|
+
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
/** The branding editor, shared by the Branding page and the setup wizard. */
|
|
14
|
+
export default function BrandingForm({ branding, onChange }) {
|
|
15
|
+
const [companyName, setCompanyName] = useState(branding.companyName);
|
|
16
|
+
const [title, setTitle] = useState(branding.title);
|
|
17
|
+
const [headerHtml, setHeaderHtml] = useState(branding.headerHtml ?? "");
|
|
18
|
+
const [footerHtml, setFooterHtml] = useState(branding.footerHtml ?? "");
|
|
19
|
+
const [logoUrlInput, setLogoUrlInput] = useState(branding.logoUrl ?? "");
|
|
20
|
+
const [iconUrlInput, setIconUrlInput] = useState(branding.iconUrl ?? "");
|
|
21
|
+
const [stylesheetUrlInput, setStylesheetUrlInput] = useState(branding.stylesheetUrl ?? "");
|
|
22
|
+
const [error, setError] = useState(null);
|
|
23
|
+
const [saving, setSaving] = useState(false);
|
|
24
|
+
const [saved, setSaved] = useState(false);
|
|
25
|
+
const [assetBusy, setAssetBusy] = useState(null);
|
|
26
|
+
const logoFileRef = useRef(null);
|
|
27
|
+
const iconFileRef = useRef(null);
|
|
28
|
+
const stylesheetFileRef = useRef(null);
|
|
29
|
+
function runAsset(name, action) {
|
|
30
|
+
setError(null);
|
|
31
|
+
setAssetBusy(name);
|
|
32
|
+
return action()
|
|
33
|
+
.then((updated) => {
|
|
34
|
+
onChange(updated);
|
|
35
|
+
setLogoUrlInput(updated.logoUrl ?? "");
|
|
36
|
+
setIconUrlInput(updated.iconUrl ?? "");
|
|
37
|
+
setStylesheetUrlInput(updated.stylesheetUrl ?? "");
|
|
38
|
+
})
|
|
39
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not update branding."))
|
|
40
|
+
.finally(() => setAssetBusy(null));
|
|
41
|
+
}
|
|
42
|
+
function handleLogoFileChange(e) {
|
|
43
|
+
const file = e.target.files?.[0];
|
|
44
|
+
if (!file)
|
|
45
|
+
return;
|
|
46
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
47
|
+
setError(`"${file.name}" is too large — logos must be 5MB or smaller.`);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
void runAsset("logo-upload", () => uploadBrandingLogo(file));
|
|
51
|
+
}
|
|
52
|
+
e.target.value = "";
|
|
53
|
+
}
|
|
54
|
+
function handleIconFileChange(e) {
|
|
55
|
+
const file = e.target.files?.[0];
|
|
56
|
+
if (!file)
|
|
57
|
+
return;
|
|
58
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
59
|
+
setError(`"${file.name}" is too large — icons must be 5MB or smaller.`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
void runAsset("icon-upload", () => uploadBrandingIcon(file));
|
|
63
|
+
}
|
|
64
|
+
e.target.value = "";
|
|
65
|
+
}
|
|
66
|
+
function handleStylesheetFileChange(e) {
|
|
67
|
+
const file = e.target.files?.[0];
|
|
68
|
+
if (!file)
|
|
69
|
+
return;
|
|
70
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
71
|
+
setError(`"${file.name}" is too large — stylesheets must be 5MB or smaller.`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
void runAsset("stylesheet-upload", () => uploadBrandingStylesheet(file));
|
|
75
|
+
}
|
|
76
|
+
e.target.value = "";
|
|
77
|
+
}
|
|
78
|
+
async function handleSubmit(e) {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
setError(null);
|
|
81
|
+
setSaved(false);
|
|
82
|
+
setSaving(true);
|
|
83
|
+
try {
|
|
84
|
+
const updated = await updateBranding({ companyName, title, headerHtml, footerHtml });
|
|
85
|
+
onChange(updated);
|
|
86
|
+
setSaved(true);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save branding.");
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
setSaving(false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return (_jsxs("div", { className: "max-w-2xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Branding" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the webmail and admin console \u2014 including anonymous booking-page visitors." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Logo" }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.logoUrl || "/images/logo.svg", alt: "Current logo", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "logo-upload", disabled: assetBusy !== null, onClick: () => logoFileRef.current?.click(), children: "Upload logo" }), _jsx("input", { ref: logoFileRef, type: "file", accept: "image/*", "aria-label": "Upload logo", className: "hidden", onChange: handleLogoFileChange }), branding.logoUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "logo-delete", onClick: () => void runAsset("logo-delete", async () => {
|
|
96
|
+
await deleteBrandingLogo();
|
|
97
|
+
return { ...branding, logoUrl: undefined };
|
|
98
|
+
}), children: "Remove logo" }))] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external image URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Logo URL", className: INPUT_CLASS, value: logoUrlInput, onChange: (e) => setLogoUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || logoUrlInput === (branding.logoUrl ?? ""), loading: assetBusy === "logo-url", onClick: () => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput })), children: "Set" })] })] })] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Icon" }), _jsx("p", { className: "text-xs text-text-muted -mt-1", children: "A compact mark for navigation headers, independent of the full logo above. Falls back to the logo, then a default asset, when not set." }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.iconUrl || branding.logoUrl || "/images/logo.svg", alt: "Current icon", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "icon-upload", disabled: assetBusy !== null, onClick: () => iconFileRef.current?.click(), children: "Upload icon" }), _jsx("input", { ref: iconFileRef, type: "file", accept: "image/*", "aria-label": "Upload icon", className: "hidden", onChange: handleIconFileChange }), branding.iconUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "icon-delete", onClick: () => void runAsset("icon-delete", async () => {
|
|
99
|
+
await deleteBrandingIcon();
|
|
100
|
+
return { ...branding, iconUrl: undefined };
|
|
101
|
+
}), children: "Remove icon" }))] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external image URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Icon URL", className: INPUT_CLASS, value: iconUrlInput, onChange: (e) => setIconUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || iconUrlInput === (branding.iconUrl ?? ""), loading: assetBusy === "icon-url", onClick: () => void runAsset("icon-url", () => updateBranding({ iconUrl: iconUrlInput })), children: "Set" })] })] })] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Custom stylesheet" }), _jsx("div", { className: "flex items-center gap-3", children: _jsx("span", { className: "text-sm text-text-muted truncate", children: branding.stylesheetUrl || "None configured" }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "stylesheet-upload", disabled: assetBusy !== null, onClick: () => stylesheetFileRef.current?.click(), children: "Upload CSS file" }), _jsx("input", { ref: stylesheetFileRef, type: "file", accept: "text/css,.css", "aria-label": "Upload stylesheet", className: "hidden", onChange: handleStylesheetFileChange }), branding.stylesheetUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "stylesheet-delete", onClick: () => void runAsset("stylesheet-delete", async () => {
|
|
102
|
+
await deleteBrandingStylesheet();
|
|
103
|
+
return { ...branding, stylesheetUrl: undefined };
|
|
104
|
+
}), children: "Remove stylesheet" }))] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external stylesheet URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Stylesheet URL", className: INPUT_CLASS, value: stylesheetUrlInput, onChange: (e) => setStylesheetUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || stylesheetUrlInput === (branding.stylesheetUrl ?? ""), loading: assetBusy === "stylesheet-url", onClick: () => void runAsset("stylesheet-url", () => updateBranding({ stylesheetUrl: stylesheetUrlInput })), children: "Set" })] })] })] }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4 pt-2 border-t border-border", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Company name" }), _jsx("input", { "aria-label": "Company name", className: INPUT_CLASS, value: companyName, onChange: (e) => setCompanyName(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Product title" }), _jsx("input", { "aria-label": "Product title", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "Shown as the browser tab title." })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Header HTML (optional)" }), _jsx("textarea", { "aria-label": "Header HTML", className: `${INPUT_CLASS} font-mono`, rows: 3, value: headerHtml, onChange: (e) => setHeaderHtml(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Footer HTML (optional)" }), _jsx("textarea", { "aria-label": "Footer HTML", className: `${INPUT_CLASS} font-mono`, rows: 3, value: footerHtml, onChange: (e) => setFooterHtml(e.target.value) })] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] })] }));
|
|
105
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { getDnsSetup, getDomain, verifyDomain } from "@rapidmx/react-shared/admin/domainsApi.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 RECORD_TYPE_LABELS = {
|
|
12
|
+
ownership: "Ownership (TXT)",
|
|
13
|
+
mx: "MX",
|
|
14
|
+
spf: "SPF",
|
|
15
|
+
dkim: "DKIM",
|
|
16
|
+
dmarc: "DMARC",
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain
|
|
20
|
+
* detail page and the setup wizard.
|
|
21
|
+
*/
|
|
22
|
+
export default function DomainDnsSetup({ uid, onLoaded }) {
|
|
23
|
+
const [domain, setDomain] = useState(null);
|
|
24
|
+
const [dnsSetup, setDnsSetup] = useState([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState(null);
|
|
27
|
+
const [verifying, setVerifying] = useState(false);
|
|
28
|
+
const [copied, setCopied] = useState(false);
|
|
29
|
+
function reload(id) {
|
|
30
|
+
setLoading(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
Promise.all([getDomain(id), getDnsSetup(id)])
|
|
33
|
+
.then(([d, checks]) => {
|
|
34
|
+
setDomain(d);
|
|
35
|
+
setDnsSetup(checks);
|
|
36
|
+
onLoaded?.(d);
|
|
37
|
+
})
|
|
38
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this domain."))
|
|
39
|
+
.finally(() => setLoading(false));
|
|
40
|
+
}
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
reload(uid);
|
|
43
|
+
}, [uid]);
|
|
44
|
+
// Only ever invoked from the "Verify now" button below, which only renders once `domain` is loaded.
|
|
45
|
+
async function handleVerify() {
|
|
46
|
+
setVerifying(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
await verifyDomain(domain.uid);
|
|
50
|
+
reload(domain.uid);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not verify this domain.");
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
setVerifying(false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function handleCopy(value) {
|
|
60
|
+
try {
|
|
61
|
+
await navigator.clipboard.writeText(value);
|
|
62
|
+
setCopied(true);
|
|
63
|
+
setTimeout(() => setCopied(false), 2000);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (loading) {
|
|
70
|
+
return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
|
|
71
|
+
}
|
|
72
|
+
if (error || !domain) {
|
|
73
|
+
return _jsx(Alert, { children: error ?? "Domain not found." });
|
|
74
|
+
}
|
|
75
|
+
const ownershipValue = dnsSetup.find((c) => c.type === "ownership")?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
|
|
76
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("code", { className: "flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap", children: ownershipValue }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => handleCopy(ownershipValue), children: copied ? "Copied" : "Copy" })] }), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Recommended value"].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: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: RECORD_TYPE_LABELS[check.type] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: _jsx("code", { className: "text-xs", children: check.recommendedValue ?? "—" }) })] }, check.type))) })] }) })] }))] }));
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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 { updateEncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.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 SELECT_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
|
+
const TIERS = [
|
|
13
|
+
{ key: "encryptSameOrg", label: "Mail within this server", help: "Messages between mailboxes hosted on this server." },
|
|
14
|
+
{
|
|
15
|
+
key: "encryptFederated",
|
|
16
|
+
label: "Mail to other RapidMX servers",
|
|
17
|
+
help: "Messages to people on another RapidMX server that publishes its encryption keys.",
|
|
18
|
+
},
|
|
19
|
+
{ key: "encryptExternal", label: "Mail to everyone else", help: "Messages to any other email provider, using S/MIME." },
|
|
20
|
+
];
|
|
21
|
+
const STATES = [
|
|
22
|
+
{ value: "automatic", label: "Always encrypt when possible" },
|
|
23
|
+
{ value: "optional", label: "Let people choose" },
|
|
24
|
+
{ value: "prohibited", label: "Never encrypt" },
|
|
25
|
+
];
|
|
26
|
+
/** Whether end-to-end encryption can be used at all under `policy` - i.e. any tier allows it. */
|
|
27
|
+
export function isEncryptionEnabled(policy) {
|
|
28
|
+
return TIERS.some((tier) => policy[tier.key] !== "prohibited");
|
|
29
|
+
}
|
|
30
|
+
/** The end-to-end encryption policy editor, shared by the Encryption Policy page and the setup wizard. */
|
|
31
|
+
export default function EncryptionPolicyForm({ policy, onChange }) {
|
|
32
|
+
const [values, setValues] = useState(policy);
|
|
33
|
+
const [error, setError] = useState(null);
|
|
34
|
+
const [saving, setSaving] = useState(false);
|
|
35
|
+
const [saved, setSaved] = useState(false);
|
|
36
|
+
async function handleSubmit(e) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
setError(null);
|
|
39
|
+
setSaved(false);
|
|
40
|
+
setSaving(true);
|
|
41
|
+
try {
|
|
42
|
+
const updated = await updateEncryptionPolicy(values);
|
|
43
|
+
onChange(updated);
|
|
44
|
+
setSaved(true);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save the encryption policy.");
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
setSaving(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "End-to-end encryption" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Encrypted messages can only be read by their senders and recipients - not by this server or its administrators. Choose how encryption applies to each kind of mail." }), 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: [TIERS.map((tier) => (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: tier.label }), _jsx("select", { "aria-label": tier.label, className: SELECT_CLASS, value: values[tier.key], onChange: (e) => {
|
|
54
|
+
setSaved(false);
|
|
55
|
+
setValues({ ...values, [tier.key]: e.target.value });
|
|
56
|
+
}, children: STATES.map((state) => (_jsx("option", { value: state.value, children: state.label }, state.value))) }), _jsx("span", { className: "text-xs text-text-muted", children: tier.help })] }, tier.key))), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
|
|
57
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, 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 Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
/** Loads a settings object, then renders its form - the loading and error states every settings page shares. */
|
|
10
|
+
export default function LoadedSettingsForm({ load, loadErrorMessage, children }) {
|
|
11
|
+
const [value, setValue] = useState(null);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
load()
|
|
15
|
+
.then(setValue)
|
|
16
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : loadErrorMessage));
|
|
17
|
+
}, []);
|
|
18
|
+
if (error) {
|
|
19
|
+
return _jsx(Alert, { children: error });
|
|
20
|
+
}
|
|
21
|
+
if (value === null) {
|
|
22
|
+
return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
|
|
23
|
+
}
|
|
24
|
+
return _jsx(_Fragment, { children: children(value, setValue) });
|
|
25
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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, useRef, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
|
|
9
|
+
import { createMailbox, listMailboxDomains } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
12
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
13
|
+
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";
|
|
14
|
+
const SELECT_CLASS = "text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
15
|
+
/**
|
|
16
|
+
* The "New mailbox" form, shared by the New Mailbox page and the setup wizard. The quota starts at the mailbox
|
|
17
|
+
* policy's default.
|
|
18
|
+
*/
|
|
19
|
+
export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "Create mailbox", cancelHref }) {
|
|
20
|
+
const [primarySmtpAddress, setPrimarySmtpAddress] = useState("");
|
|
21
|
+
const [localPart, setLocalPart] = useState(defaults?.localPart ?? "");
|
|
22
|
+
const [domains, setDomains] = useState([]);
|
|
23
|
+
const [domain, setDomain] = useState("");
|
|
24
|
+
const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
|
|
25
|
+
const [ownerUserUid, setOwnerUserUid] = useState(defaults?.ownerUserUid ?? "");
|
|
26
|
+
const [timezone, setTimezone] = useState("UTC");
|
|
27
|
+
const [quotaGb, setQuotaGb] = useState(5);
|
|
28
|
+
// Only replaced by the mailbox policy's default if the admin hasn't typed a quota of their own yet.
|
|
29
|
+
const quotaTouched = useRef(false);
|
|
30
|
+
const [isResource, setIsResource] = useState(false);
|
|
31
|
+
const [resourceType, setResourceType] = useState("room");
|
|
32
|
+
const [resourceCapacity, setResourceCapacity] = useState("");
|
|
33
|
+
const [autoAcceptBookings, setAutoAcceptBookings] = useState(false);
|
|
34
|
+
const [allowConflicts, setAllowConflicts] = useState(false);
|
|
35
|
+
const [bookingWindowDays, setBookingWindowDays] = useState("");
|
|
36
|
+
const [maxDurationMinutes, setMaxDurationMinutes] = useState("");
|
|
37
|
+
const [error, setError] = useState(null);
|
|
38
|
+
const [saving, setSaving] = useState(false);
|
|
39
|
+
// An empty `mail:domains` list (the default) means this server enforces no domain restriction —
|
|
40
|
+
// the address field stays free text, exactly as before. Once an admin configures at least one
|
|
41
|
+
// domain, every mailbox this server creates must be on one of them (enforced server-side too, in
|
|
42
|
+
// `BaseMailboxRoute.create()`), so the form switches to a local-part input + domain dropdown to
|
|
43
|
+
// make that restriction visible rather than let an admin discover it only via a rejected submit.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
listMailboxDomains()
|
|
46
|
+
.then((list) => {
|
|
47
|
+
setDomains(list);
|
|
48
|
+
setDomain(defaults?.domain && list.includes(defaults.domain) ? defaults.domain : (list[0] ?? ""));
|
|
49
|
+
})
|
|
50
|
+
.catch(() => undefined);
|
|
51
|
+
getMailboxPolicy()
|
|
52
|
+
.then((policy) => {
|
|
53
|
+
if (!quotaTouched.current) {
|
|
54
|
+
setQuotaGb(Math.round(policy.defaultQuotaBytes / 1000000000));
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.catch(() => undefined);
|
|
58
|
+
}, []);
|
|
59
|
+
const constrained = domains.length > 0;
|
|
60
|
+
async function handleSubmit(e) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setError(null);
|
|
63
|
+
const address = constrained ? `${localPart.trim()}@${domain}` : primarySmtpAddress.trim();
|
|
64
|
+
if (constrained ? !localPart.trim() : !primarySmtpAddress.trim()) {
|
|
65
|
+
setError("A primary SMTP address is required.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!displayName.trim()) {
|
|
69
|
+
setError("A display name is required.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setSaving(true);
|
|
73
|
+
try {
|
|
74
|
+
// ownerUserUid left blank creates a true ownerless shared mailbox (e.g. support@example.com);
|
|
75
|
+
// delegates are then granted access from the mailbox's detail page. A resource mailbox is
|
|
76
|
+
// ownerless the same way — leave "Owner user uid" blank for one too.
|
|
77
|
+
const mailbox = await createMailbox({
|
|
78
|
+
primarySmtpAddress: address,
|
|
79
|
+
displayName: displayName.trim(),
|
|
80
|
+
ownerUserUid: ownerUserUid.trim() || undefined,
|
|
81
|
+
timezone,
|
|
82
|
+
quotaBytes: Math.round(quotaGb * 1000000000),
|
|
83
|
+
...(isResource && {
|
|
84
|
+
isResource: true,
|
|
85
|
+
resourceType,
|
|
86
|
+
resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
|
|
87
|
+
autoAcceptBookings,
|
|
88
|
+
allowConflicts,
|
|
89
|
+
bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
|
|
90
|
+
maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
onCreated(mailbox);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create the mailbox.");
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
setSaving(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return (_jsxs(_Fragment, { children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [constrained ? (_jsx(FormField, { label: "Local part", htmlFor: "localPart", children: _jsxs("div", { className: "flex gap-2 items-center", children: [_jsx("input", { id: "localPart", type: "text", className: INPUT_CLASS, value: localPart, onChange: (e) => setLocalPart(e.target.value), placeholder: "support" }), _jsx("span", { className: "text-text-muted shrink-0", children: "@" }), _jsx("select", { "aria-label": "Domain", className: SELECT_CLASS, value: domain, onChange: (e) => setDomain(e.target.value), children: domains.map((d) => (_jsx("option", { value: d, children: d }, d))) })] }) })) : (_jsx(FormField, { label: "Primary SMTP address", htmlFor: "primarySmtpAddress", children: _jsx("input", { id: "primarySmtpAddress", type: "email", className: INPUT_CLASS, value: primarySmtpAddress, onChange: (e) => setPrimarySmtpAddress(e.target.value), placeholder: "support@example.com" }) })), _jsx(FormField, { label: "Display name", htmlFor: "displayName", children: _jsx("input", { id: "displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value), placeholder: "Support" }) }), _jsx(FormField, { label: "Owner user uid (optional)", htmlFor: "ownerUserUid", children: _jsx("input", { id: "ownerUserUid", type: "text", className: INPUT_CLASS, value: ownerUserUid, onChange: (e) => setOwnerUserUid(e.target.value), placeholder: "Leave blank for a shared mailbox" }) }), _jsx(FormField, { label: "Timezone", htmlFor: "timezone", children: _jsx("input", { id: "timezone", type: "text", className: INPUT_CLASS, value: timezone, onChange: (e) => setTimezone(e.target.value), placeholder: "America/Los_Angeles" }) }), _jsx(FormField, { label: "Quota (GB)", htmlFor: "quotaGb", children: _jsx("input", { id: "quotaGb", type: "number", min: 1, step: 1, className: INPUT_CLASS, value: quotaGb, onChange: (e) => {
|
|
103
|
+
quotaTouched.current = true;
|
|
104
|
+
setQuotaGb(Number(e.target.value));
|
|
105
|
+
} }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: isResource, onChange: (e) => setIsResource(e.target.checked) }), "This is a resource mailbox (a bookable room or piece of equipment)"] }), isResource && (_jsxs("div", { className: "border border-border rounded-sm p-4 mb-4 flex flex-col gap-4", children: [_jsx(FormField, { label: "Resource type", htmlFor: "resourceType", children: _jsxs("select", { id: "resourceType", className: SELECT_CLASS, value: resourceType, onChange: (e) => setResourceType(e.target.value), children: [_jsx("option", { value: "room", children: "Room" }), _jsx("option", { value: "equipment", children: "Equipment" })] }) }), _jsx(FormField, { label: "Capacity (optional)", htmlFor: "resourceCapacity", children: _jsx("input", { id: "resourceCapacity", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: resourceCapacity, onChange: (e) => setResourceCapacity(e.target.value), placeholder: "Informational only" }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: autoAcceptBookings, onChange: (e) => setAutoAcceptBookings(e.target.checked) }), "Automatically accept booking requests"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: allowConflicts, onChange: (e) => setAllowConflicts(e.target.checked) }), "Allow conflicting bookings (skip conflict checking entirely)"] }), _jsx(FormField, { label: "Booking window, in days (optional)", htmlFor: "bookingWindowDays", children: _jsx("input", { id: "bookingWindowDays", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: bookingWindowDays, onChange: (e) => setBookingWindowDays(e.target.value), placeholder: "No limit" }) }), _jsx(FormField, { label: "Maximum duration, in minutes (optional)", htmlFor: "maxDurationMinutes", children: _jsx("input", { id: "maxDurationMinutes", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: maxDurationMinutes, onChange: (e) => setMaxDurationMinutes(e.target.value), placeholder: "No limit" }) })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: submitLabel }), cancelHref && (_jsx("a", { href: cancelHref, children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) }))] })] })] }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { updateMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.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
|
+
const GB = 1000000000;
|
|
13
|
+
function toGb(bytes) {
|
|
14
|
+
return String(Math.round((bytes / GB) * 100) / 100);
|
|
15
|
+
}
|
|
16
|
+
/** The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. */
|
|
17
|
+
export default function MailboxPolicyForm({ policy, onChange }) {
|
|
18
|
+
const [defaultQuotaGb, setDefaultQuotaGb] = useState(toGb(policy.defaultQuotaBytes));
|
|
19
|
+
const [autoProvisionEnabled, setAutoProvisionEnabled] = useState(policy.autoProvisionEnabled);
|
|
20
|
+
const [autoProvisionQuotaGb, setAutoProvisionQuotaGb] = useState(toGb(policy.autoProvisionQuotaBytes));
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
const [saving, setSaving] = useState(false);
|
|
23
|
+
const [saved, setSaved] = useState(false);
|
|
24
|
+
async function handleSubmit(e) {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
setError(null);
|
|
27
|
+
setSaved(false);
|
|
28
|
+
const defaultQuota = Number(defaultQuotaGb);
|
|
29
|
+
const autoQuota = Number(autoProvisionQuotaGb);
|
|
30
|
+
if (!(defaultQuota > 0) || !(autoQuota > 0)) {
|
|
31
|
+
setError("Quotas must be more than 0 GB.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
setSaving(true);
|
|
35
|
+
try {
|
|
36
|
+
const updated = await updateMailboxPolicy({
|
|
37
|
+
defaultQuotaBytes: Math.round(defaultQuota * GB),
|
|
38
|
+
autoProvisionEnabled,
|
|
39
|
+
autoProvisionQuotaBytes: Math.round(autoQuota * GB),
|
|
40
|
+
});
|
|
41
|
+
onChange(updated);
|
|
42
|
+
setSaved(true);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save the mailbox policy.");
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
setSaving(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "Mailboxes" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Defaults for new mailboxes on this server." }), 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: "Default quota (GB)" }), _jsx("input", { "aria-label": "Default quota (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: defaultQuotaGb, onChange: (e) => setDefaultQuotaGb(e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "The storage a mailbox starts with when an administrator creates it." })] }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: autoProvisionEnabled, onChange: (e) => setAutoProvisionEnabled(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Let people create their own mailbox" }), _jsx("span", { className: "text-xs text-text-muted", children: "Someone who signs in without a mailbox can create one, using their username on one of this server\u2019s verified domains." })] })] }), autoProvisionEnabled && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Quota for self-created mailboxes (GB)" }), _jsx("input", { "aria-label": "Quota for self-created mailboxes (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: autoProvisionQuotaGb, onChange: (e) => setAutoProvisionQuotaGb(e.target.value) })] })), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
|
|
52
|
+
}
|