@rapidmx/web-client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -0,0 +1,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&hellip;</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&rsquo;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
+ }