@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,34 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
8
|
+
|
|
9
|
+
export interface LoadedSettingsFormProps<T> {
|
|
10
|
+
load: () => Promise<T>;
|
|
11
|
+
/** Shown when loading fails with something other than an API error message. */
|
|
12
|
+
loadErrorMessage: string;
|
|
13
|
+
children: (value: T, onChange: (value: T) => void) => ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Loads a settings object, then renders its form - the loading and error states every settings page shares. */
|
|
17
|
+
export default function LoadedSettingsForm<T>({ load, loadErrorMessage, children }: LoadedSettingsFormProps<T>) {
|
|
18
|
+
const [value, setValue] = useState<T | null>(null);
|
|
19
|
+
const [error, setError] = useState<string | null>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
load()
|
|
23
|
+
.then(setValue)
|
|
24
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : loadErrorMessage));
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
if (error) {
|
|
28
|
+
return <Alert>{error}</Alert>;
|
|
29
|
+
}
|
|
30
|
+
if (value === null) {
|
|
31
|
+
return <p className="text-sm text-text-muted">Loading…</p>;
|
|
32
|
+
}
|
|
33
|
+
return <>{children(value, setValue)}</>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
|
|
8
|
+
import { createMailbox, listMailboxDomains, Mailbox } from "@rapidmx/react-shared/mail/mailApi.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 FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
12
|
+
|
|
13
|
+
const INPUT_CLASS =
|
|
14
|
+
"w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
15
|
+
const SELECT_CLASS =
|
|
16
|
+
"text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
17
|
+
|
|
18
|
+
export interface MailboxCreateFormProps {
|
|
19
|
+
onCreated: (mailbox: Mailbox) => void;
|
|
20
|
+
/** Initial field values, e.g. the signed-in admin's own mailbox in the setup wizard. */
|
|
21
|
+
defaults?: { localPart?: string; domain?: string; displayName?: string; ownerUserUid?: string };
|
|
22
|
+
submitLabel?: string;
|
|
23
|
+
cancelHref?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The "New mailbox" form, shared by the New Mailbox page and the setup wizard. The quota starts at the mailbox
|
|
28
|
+
* policy's default.
|
|
29
|
+
*/
|
|
30
|
+
export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "Create mailbox", cancelHref }: MailboxCreateFormProps) {
|
|
31
|
+
const [primarySmtpAddress, setPrimarySmtpAddress] = useState("");
|
|
32
|
+
const [localPart, setLocalPart] = useState(defaults?.localPart ?? "");
|
|
33
|
+
const [domains, setDomains] = useState<string[]>([]);
|
|
34
|
+
const [domain, setDomain] = useState("");
|
|
35
|
+
const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
|
|
36
|
+
const [ownerUserUid, setOwnerUserUid] = useState(defaults?.ownerUserUid ?? "");
|
|
37
|
+
const [timezone, setTimezone] = useState("UTC");
|
|
38
|
+
const [quotaGb, setQuotaGb] = useState(5);
|
|
39
|
+
// Only replaced by the mailbox policy's default if the admin hasn't typed a quota of their own yet.
|
|
40
|
+
const quotaTouched = useRef(false);
|
|
41
|
+
const [isResource, setIsResource] = useState(false);
|
|
42
|
+
const [resourceType, setResourceType] = useState<"room" | "equipment">("room");
|
|
43
|
+
const [resourceCapacity, setResourceCapacity] = useState("");
|
|
44
|
+
const [autoAcceptBookings, setAutoAcceptBookings] = useState(false);
|
|
45
|
+
const [allowConflicts, setAllowConflicts] = useState(false);
|
|
46
|
+
const [bookingWindowDays, setBookingWindowDays] = useState("");
|
|
47
|
+
const [maxDurationMinutes, setMaxDurationMinutes] = useState("");
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const [saving, setSaving] = useState(false);
|
|
50
|
+
|
|
51
|
+
// An empty `mail:domains` list (the default) means this server enforces no domain restriction —
|
|
52
|
+
// the address field stays free text, exactly as before. Once an admin configures at least one
|
|
53
|
+
// domain, every mailbox this server creates must be on one of them (enforced server-side too, in
|
|
54
|
+
// `BaseMailboxRoute.create()`), so the form switches to a local-part input + domain dropdown to
|
|
55
|
+
// make that restriction visible rather than let an admin discover it only via a rejected submit.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
listMailboxDomains()
|
|
58
|
+
.then((list) => {
|
|
59
|
+
setDomains(list);
|
|
60
|
+
setDomain(defaults?.domain && list.includes(defaults.domain) ? defaults.domain : (list[0] ?? ""));
|
|
61
|
+
})
|
|
62
|
+
.catch(() => undefined);
|
|
63
|
+
getMailboxPolicy()
|
|
64
|
+
.then((policy) => {
|
|
65
|
+
if (!quotaTouched.current) {
|
|
66
|
+
setQuotaGb(Math.round(policy.defaultQuotaBytes / 1_000_000_000));
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.catch(() => undefined);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const constrained = domains.length > 0;
|
|
73
|
+
|
|
74
|
+
async function handleSubmit(e: FormEvent) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
setError(null);
|
|
77
|
+
|
|
78
|
+
const address = constrained ? `${localPart.trim()}@${domain}` : primarySmtpAddress.trim();
|
|
79
|
+
if (constrained ? !localPart.trim() : !primarySmtpAddress.trim()) {
|
|
80
|
+
setError("A primary SMTP address is required.");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!displayName.trim()) {
|
|
84
|
+
setError("A display name is required.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setSaving(true);
|
|
89
|
+
try {
|
|
90
|
+
// ownerUserUid left blank creates a true ownerless shared mailbox (e.g. support@example.com);
|
|
91
|
+
// delegates are then granted access from the mailbox's detail page. A resource mailbox is
|
|
92
|
+
// ownerless the same way — leave "Owner user uid" blank for one too.
|
|
93
|
+
const mailbox = await createMailbox({
|
|
94
|
+
primarySmtpAddress: address,
|
|
95
|
+
displayName: displayName.trim(),
|
|
96
|
+
ownerUserUid: ownerUserUid.trim() || undefined,
|
|
97
|
+
timezone,
|
|
98
|
+
quotaBytes: Math.round(quotaGb * 1_000_000_000),
|
|
99
|
+
...(isResource && {
|
|
100
|
+
isResource: true,
|
|
101
|
+
resourceType,
|
|
102
|
+
resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
|
|
103
|
+
autoAcceptBookings,
|
|
104
|
+
allowConflicts,
|
|
105
|
+
bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
|
|
106
|
+
maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
onCreated(mailbox);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create the mailbox.");
|
|
112
|
+
} finally {
|
|
113
|
+
setSaving(false);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<>
|
|
119
|
+
{error && <Alert>{error}</Alert>}
|
|
120
|
+
|
|
121
|
+
<form onSubmit={handleSubmit} className="bg-surface border border-border rounded-md p-6">
|
|
122
|
+
{constrained ? (
|
|
123
|
+
<FormField label="Local part" htmlFor="localPart">
|
|
124
|
+
<div className="flex gap-2 items-center">
|
|
125
|
+
<input
|
|
126
|
+
id="localPart"
|
|
127
|
+
type="text"
|
|
128
|
+
className={INPUT_CLASS}
|
|
129
|
+
value={localPart}
|
|
130
|
+
onChange={(e) => setLocalPart(e.target.value)}
|
|
131
|
+
placeholder="support"
|
|
132
|
+
/>
|
|
133
|
+
<span className="text-text-muted shrink-0">@</span>
|
|
134
|
+
<select
|
|
135
|
+
aria-label="Domain"
|
|
136
|
+
className={SELECT_CLASS}
|
|
137
|
+
value={domain}
|
|
138
|
+
onChange={(e) => setDomain(e.target.value)}
|
|
139
|
+
>
|
|
140
|
+
{domains.map((d) => (
|
|
141
|
+
<option key={d} value={d}>
|
|
142
|
+
{d}
|
|
143
|
+
</option>
|
|
144
|
+
))}
|
|
145
|
+
</select>
|
|
146
|
+
</div>
|
|
147
|
+
</FormField>
|
|
148
|
+
) : (
|
|
149
|
+
<FormField label="Primary SMTP address" htmlFor="primarySmtpAddress">
|
|
150
|
+
<input
|
|
151
|
+
id="primarySmtpAddress"
|
|
152
|
+
type="email"
|
|
153
|
+
className={INPUT_CLASS}
|
|
154
|
+
value={primarySmtpAddress}
|
|
155
|
+
onChange={(e) => setPrimarySmtpAddress(e.target.value)}
|
|
156
|
+
placeholder="support@example.com"
|
|
157
|
+
/>
|
|
158
|
+
</FormField>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
<FormField label="Display name" htmlFor="displayName">
|
|
162
|
+
<input
|
|
163
|
+
id="displayName"
|
|
164
|
+
type="text"
|
|
165
|
+
className={INPUT_CLASS}
|
|
166
|
+
value={displayName}
|
|
167
|
+
onChange={(e) => setDisplayName(e.target.value)}
|
|
168
|
+
placeholder="Support"
|
|
169
|
+
/>
|
|
170
|
+
</FormField>
|
|
171
|
+
|
|
172
|
+
<FormField label="Owner user uid (optional)" htmlFor="ownerUserUid">
|
|
173
|
+
<input
|
|
174
|
+
id="ownerUserUid"
|
|
175
|
+
type="text"
|
|
176
|
+
className={INPUT_CLASS}
|
|
177
|
+
value={ownerUserUid}
|
|
178
|
+
onChange={(e) => setOwnerUserUid(e.target.value)}
|
|
179
|
+
placeholder="Leave blank for a shared mailbox"
|
|
180
|
+
/>
|
|
181
|
+
</FormField>
|
|
182
|
+
|
|
183
|
+
<FormField label="Timezone" htmlFor="timezone">
|
|
184
|
+
<input
|
|
185
|
+
id="timezone"
|
|
186
|
+
type="text"
|
|
187
|
+
className={INPUT_CLASS}
|
|
188
|
+
value={timezone}
|
|
189
|
+
onChange={(e) => setTimezone(e.target.value)}
|
|
190
|
+
placeholder="America/Los_Angeles"
|
|
191
|
+
/>
|
|
192
|
+
</FormField>
|
|
193
|
+
|
|
194
|
+
<FormField label="Quota (GB)" htmlFor="quotaGb">
|
|
195
|
+
<input
|
|
196
|
+
id="quotaGb"
|
|
197
|
+
type="number"
|
|
198
|
+
min={1}
|
|
199
|
+
step={1}
|
|
200
|
+
className={INPUT_CLASS}
|
|
201
|
+
value={quotaGb}
|
|
202
|
+
onChange={(e) => {
|
|
203
|
+
quotaTouched.current = true;
|
|
204
|
+
setQuotaGb(Number(e.target.value));
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
</FormField>
|
|
208
|
+
|
|
209
|
+
<label className="flex items-center gap-2 text-sm mb-4">
|
|
210
|
+
<input type="checkbox" checked={isResource} onChange={(e) => setIsResource(e.target.checked)} />
|
|
211
|
+
This is a resource mailbox (a bookable room or piece of equipment)
|
|
212
|
+
</label>
|
|
213
|
+
|
|
214
|
+
{isResource && (
|
|
215
|
+
<div className="border border-border rounded-sm p-4 mb-4 flex flex-col gap-4">
|
|
216
|
+
<FormField label="Resource type" htmlFor="resourceType">
|
|
217
|
+
<select
|
|
218
|
+
id="resourceType"
|
|
219
|
+
className={SELECT_CLASS}
|
|
220
|
+
value={resourceType}
|
|
221
|
+
onChange={(e) => setResourceType(e.target.value as "room" | "equipment")}
|
|
222
|
+
>
|
|
223
|
+
<option value="room">Room</option>
|
|
224
|
+
<option value="equipment">Equipment</option>
|
|
225
|
+
</select>
|
|
226
|
+
</FormField>
|
|
227
|
+
|
|
228
|
+
<FormField label="Capacity (optional)" htmlFor="resourceCapacity">
|
|
229
|
+
<input
|
|
230
|
+
id="resourceCapacity"
|
|
231
|
+
type="number"
|
|
232
|
+
min={0}
|
|
233
|
+
step={1}
|
|
234
|
+
className={INPUT_CLASS}
|
|
235
|
+
value={resourceCapacity}
|
|
236
|
+
onChange={(e) => setResourceCapacity(e.target.value)}
|
|
237
|
+
placeholder="Informational only"
|
|
238
|
+
/>
|
|
239
|
+
</FormField>
|
|
240
|
+
|
|
241
|
+
<label className="flex items-center gap-2 text-sm">
|
|
242
|
+
<input
|
|
243
|
+
type="checkbox"
|
|
244
|
+
checked={autoAcceptBookings}
|
|
245
|
+
onChange={(e) => setAutoAcceptBookings(e.target.checked)}
|
|
246
|
+
/>
|
|
247
|
+
Automatically accept booking requests
|
|
248
|
+
</label>
|
|
249
|
+
|
|
250
|
+
<label className="flex items-center gap-2 text-sm">
|
|
251
|
+
<input
|
|
252
|
+
type="checkbox"
|
|
253
|
+
checked={allowConflicts}
|
|
254
|
+
onChange={(e) => setAllowConflicts(e.target.checked)}
|
|
255
|
+
/>
|
|
256
|
+
Allow conflicting bookings (skip conflict checking entirely)
|
|
257
|
+
</label>
|
|
258
|
+
|
|
259
|
+
<FormField label="Booking window, in days (optional)" htmlFor="bookingWindowDays">
|
|
260
|
+
<input
|
|
261
|
+
id="bookingWindowDays"
|
|
262
|
+
type="number"
|
|
263
|
+
min={0}
|
|
264
|
+
step={1}
|
|
265
|
+
className={INPUT_CLASS}
|
|
266
|
+
value={bookingWindowDays}
|
|
267
|
+
onChange={(e) => setBookingWindowDays(e.target.value)}
|
|
268
|
+
placeholder="No limit"
|
|
269
|
+
/>
|
|
270
|
+
</FormField>
|
|
271
|
+
|
|
272
|
+
<FormField label="Maximum duration, in minutes (optional)" htmlFor="maxDurationMinutes">
|
|
273
|
+
<input
|
|
274
|
+
id="maxDurationMinutes"
|
|
275
|
+
type="number"
|
|
276
|
+
min={0}
|
|
277
|
+
step={1}
|
|
278
|
+
className={INPUT_CLASS}
|
|
279
|
+
value={maxDurationMinutes}
|
|
280
|
+
onChange={(e) => setMaxDurationMinutes(e.target.value)}
|
|
281
|
+
placeholder="No limit"
|
|
282
|
+
/>
|
|
283
|
+
</FormField>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
<div className="flex gap-3 mt-2">
|
|
288
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
289
|
+
{submitLabel}
|
|
290
|
+
</Button>
|
|
291
|
+
{cancelHref && (
|
|
292
|
+
<a href={cancelHref}>
|
|
293
|
+
<Button type="button" variant="secondary" className="!w-auto">
|
|
294
|
+
Cancel
|
|
295
|
+
</Button>
|
|
296
|
+
</a>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
</form>
|
|
300
|
+
</>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { MailboxPolicy, updateMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
10
|
+
|
|
11
|
+
const INPUT_CLASS =
|
|
12
|
+
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
13
|
+
|
|
14
|
+
const GB = 1_000_000_000;
|
|
15
|
+
|
|
16
|
+
function toGb(bytes: number): string {
|
|
17
|
+
return String(Math.round((bytes / GB) * 100) / 100);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MailboxPolicyFormProps {
|
|
21
|
+
policy: MailboxPolicy;
|
|
22
|
+
onChange: (policy: MailboxPolicy) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. */
|
|
26
|
+
export default function MailboxPolicyForm({ policy, onChange }: MailboxPolicyFormProps) {
|
|
27
|
+
const [defaultQuotaGb, setDefaultQuotaGb] = useState(toGb(policy.defaultQuotaBytes));
|
|
28
|
+
const [autoProvisionEnabled, setAutoProvisionEnabled] = useState(policy.autoProvisionEnabled);
|
|
29
|
+
const [autoProvisionQuotaGb, setAutoProvisionQuotaGb] = useState(toGb(policy.autoProvisionQuotaBytes));
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
const [saving, setSaving] = useState(false);
|
|
32
|
+
const [saved, setSaved] = useState(false);
|
|
33
|
+
|
|
34
|
+
async function handleSubmit(e: FormEvent) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
setError(null);
|
|
37
|
+
setSaved(false);
|
|
38
|
+
const defaultQuota = Number(defaultQuotaGb);
|
|
39
|
+
const autoQuota = Number(autoProvisionQuotaGb);
|
|
40
|
+
if (!(defaultQuota > 0) || !(autoQuota > 0)) {
|
|
41
|
+
setError("Quotas must be more than 0 GB.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setSaving(true);
|
|
45
|
+
try {
|
|
46
|
+
const updated = await updateMailboxPolicy({
|
|
47
|
+
defaultQuotaBytes: Math.round(defaultQuota * GB),
|
|
48
|
+
autoProvisionEnabled,
|
|
49
|
+
autoProvisionQuotaBytes: Math.round(autoQuota * GB),
|
|
50
|
+
});
|
|
51
|
+
onChange(updated);
|
|
52
|
+
setSaved(true);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save the mailbox policy.");
|
|
55
|
+
} finally {
|
|
56
|
+
setSaving(false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="max-w-xl">
|
|
62
|
+
<h2 className="text-lg font-bold uppercase tracking-wide mb-1">Mailboxes</h2>
|
|
63
|
+
<p className="text-sm text-text-muted mb-5">Defaults for new mailboxes on this server.</p>
|
|
64
|
+
|
|
65
|
+
{error && <Alert>{error}</Alert>}
|
|
66
|
+
{saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
|
|
67
|
+
|
|
68
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
69
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
70
|
+
<span className="font-semibold">Default quota (GB)</span>
|
|
71
|
+
<input
|
|
72
|
+
aria-label="Default quota (GB)"
|
|
73
|
+
type="number"
|
|
74
|
+
min={0}
|
|
75
|
+
step="any"
|
|
76
|
+
className={INPUT_CLASS}
|
|
77
|
+
value={defaultQuotaGb}
|
|
78
|
+
onChange={(e) => setDefaultQuotaGb(e.target.value)}
|
|
79
|
+
/>
|
|
80
|
+
<span className="text-xs text-text-muted">The storage a mailbox starts with when an administrator creates it.</span>
|
|
81
|
+
</label>
|
|
82
|
+
<label className="flex items-start gap-2 text-sm">
|
|
83
|
+
<input
|
|
84
|
+
type="checkbox"
|
|
85
|
+
className="mt-1"
|
|
86
|
+
checked={autoProvisionEnabled}
|
|
87
|
+
onChange={(e) => setAutoProvisionEnabled(e.target.checked)}
|
|
88
|
+
/>
|
|
89
|
+
<span>
|
|
90
|
+
<span className="font-semibold block">Let people create their own mailbox</span>
|
|
91
|
+
<span className="text-xs text-text-muted">
|
|
92
|
+
Someone who signs in without a mailbox can create one, using their username on one of this
|
|
93
|
+
server’s verified domains.
|
|
94
|
+
</span>
|
|
95
|
+
</span>
|
|
96
|
+
</label>
|
|
97
|
+
{autoProvisionEnabled && (
|
|
98
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
99
|
+
<span className="font-semibold">Quota for self-created mailboxes (GB)</span>
|
|
100
|
+
<input
|
|
101
|
+
aria-label="Quota for self-created mailboxes (GB)"
|
|
102
|
+
type="number"
|
|
103
|
+
min={0}
|
|
104
|
+
step="any"
|
|
105
|
+
className={INPUT_CLASS}
|
|
106
|
+
value={autoProvisionQuotaGb}
|
|
107
|
+
onChange={(e) => setAutoProvisionQuotaGb(e.target.value)}
|
|
108
|
+
/>
|
|
109
|
+
</label>
|
|
110
|
+
)}
|
|
111
|
+
<div>
|
|
112
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
113
|
+
Save
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
</form>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|