@rapidmx/web-client 0.3.1 → 0.5.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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -0,0 +1,112 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { EncryptionPolicy, PolicyState, updateEncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.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 SELECT_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 TIERS: { key: keyof EncryptionPolicy; label: string; help: string }[] = [
15
+ { key: "encryptSameOrg", label: "Mail within this server", help: "Messages between mailboxes hosted on this server." },
16
+ {
17
+ key: "encryptFederated",
18
+ label: "Mail to other RapidMX servers",
19
+ help: "Messages to people on another RapidMX server that publishes its encryption keys.",
20
+ },
21
+ { key: "encryptExternal", label: "Mail to everyone else", help: "Messages to any other email provider, using S/MIME." },
22
+ ];
23
+
24
+ const STATES: { value: PolicyState; label: string }[] = [
25
+ { value: "automatic", label: "Always encrypt when possible" },
26
+ { value: "optional", label: "Let people choose" },
27
+ { value: "prohibited", label: "Never encrypt" },
28
+ ];
29
+
30
+ /** Whether end-to-end encryption can be used at all under `policy` - i.e. any tier allows it. */
31
+ export function isEncryptionEnabled(policy: EncryptionPolicy): boolean {
32
+ return TIERS.some((tier) => policy[tier.key] !== "prohibited");
33
+ }
34
+
35
+ export interface EncryptionPolicyFormProps {
36
+ policy: EncryptionPolicy;
37
+ onChange: (policy: EncryptionPolicy) => void;
38
+ /** Told whether the form has edits that haven't been saved. */
39
+ onDirtyChange?: (dirty: boolean) => void;
40
+ }
41
+
42
+ /** The end-to-end encryption policy editor, shared by the Encryption Policy page and the setup wizard. */
43
+ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange }: EncryptionPolicyFormProps) {
44
+ const [values, setValues] = useState<EncryptionPolicy>(policy);
45
+ const [error, setError] = useState<string | null>(null);
46
+ const [saving, setSaving] = useState(false);
47
+ const [saved, setSaved] = useState(false);
48
+
49
+ // Edits are unsaved until they match what was loaded or last saved.
50
+ const [savedValues, setSavedValues] = useState<EncryptionPolicy>(policy);
51
+ const dirty: boolean = TIERS.some((tier) => values[tier.key] !== savedValues[tier.key]);
52
+ useEffect(() => onDirtyChange?.(dirty), [dirty]);
53
+
54
+ async function handleSubmit(e: FormEvent) {
55
+ e.preventDefault();
56
+ setError(null);
57
+ setSaved(false);
58
+ setSaving(true);
59
+ try {
60
+ const updated = await updateEncryptionPolicy(values);
61
+ onChange(updated);
62
+ setSavedValues(values);
63
+ setSaved(true);
64
+ } catch (err) {
65
+ setError(err instanceof ApiRequestError ? err.message : "Could not save the encryption policy.");
66
+ } finally {
67
+ setSaving(false);
68
+ }
69
+ }
70
+
71
+ return (
72
+ <div className="max-w-xl">
73
+ <h2 className="text-lg font-bold uppercase tracking-wide mb-1">End-to-end encryption</h2>
74
+ <p className="text-sm text-text-muted mb-5">
75
+ Encrypted messages can only be read by their senders and recipients - not by this server or its
76
+ administrators. Choose how encryption applies to each kind of mail.
77
+ </p>
78
+
79
+ {error && <Alert>{error}</Alert>}
80
+ {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
81
+
82
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
83
+ {TIERS.map((tier) => (
84
+ <label key={tier.key} className="flex flex-col gap-1.5 text-sm">
85
+ <span className="font-semibold">{tier.label}</span>
86
+ <select
87
+ aria-label={tier.label}
88
+ className={SELECT_CLASS}
89
+ value={values[tier.key]}
90
+ onChange={(e) => {
91
+ setSaved(false);
92
+ setValues({ ...values, [tier.key]: e.target.value as PolicyState });
93
+ }}
94
+ >
95
+ {STATES.map((state) => (
96
+ <option key={state.value} value={state.value}>
97
+ {state.label}
98
+ </option>
99
+ ))}
100
+ </select>
101
+ <span className="text-xs text-text-muted">{tier.help}</span>
102
+ </label>
103
+ ))}
104
+ <div>
105
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
106
+ Save
107
+ </Button>
108
+ </div>
109
+ </form>
110
+ </div>
111
+ );
112
+ }
@@ -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,308 @@
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
+ // restapi refuses these (400): a display name shown in From must not look like an address. The fullwidth and
88
+ // small look-alike @ signs are refused too (restapi's `AT_SIGN_LIKE`).
89
+ if (/[@@﹫\r\n]/.test(displayName)) {
90
+ setError("A display name can't contain \"@\" (or a look-alike) or line breaks.");
91
+ return;
92
+ }
93
+
94
+ setSaving(true);
95
+ try {
96
+ // ownerUserUid left blank creates a true ownerless shared mailbox (e.g. support@example.com);
97
+ // delegates are then granted access from the mailbox's detail page. A resource mailbox is
98
+ // ownerless the same way — leave "Owner user uid" blank for one too.
99
+ const mailbox = await createMailbox({
100
+ primarySmtpAddress: address,
101
+ displayName: displayName.trim(),
102
+ ownerUserUid: ownerUserUid.trim() || undefined,
103
+ timezone,
104
+ quotaBytes: Math.round(quotaGb * 1_000_000_000),
105
+ ...(isResource && {
106
+ isResource: true,
107
+ resourceType,
108
+ resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
109
+ autoAcceptBookings,
110
+ allowConflicts,
111
+ bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
112
+ maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
113
+ }),
114
+ });
115
+ onCreated(mailbox);
116
+ } catch (err) {
117
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the mailbox.");
118
+ } finally {
119
+ setSaving(false);
120
+ }
121
+ }
122
+
123
+ return (
124
+ <>
125
+ {error && <Alert>{error}</Alert>}
126
+
127
+ <form onSubmit={handleSubmit} className="bg-surface border border-border rounded-md p-6">
128
+ {constrained ? (
129
+ <FormField label="Local part" htmlFor="localPart">
130
+ <div className="flex gap-2 items-center">
131
+ <input
132
+ id="localPart"
133
+ type="text"
134
+ className={INPUT_CLASS}
135
+ value={localPart}
136
+ onChange={(e) => setLocalPart(e.target.value)}
137
+ placeholder="support"
138
+ />
139
+ <span className="text-text-muted shrink-0">@</span>
140
+ <select
141
+ aria-label="Domain"
142
+ className={SELECT_CLASS}
143
+ value={domain}
144
+ onChange={(e) => setDomain(e.target.value)}
145
+ >
146
+ {domains.map((d) => (
147
+ <option key={d} value={d}>
148
+ {d}
149
+ </option>
150
+ ))}
151
+ </select>
152
+ </div>
153
+ </FormField>
154
+ ) : (
155
+ <FormField label="Primary SMTP address" htmlFor="primarySmtpAddress">
156
+ <input
157
+ id="primarySmtpAddress"
158
+ type="email"
159
+ className={INPUT_CLASS}
160
+ value={primarySmtpAddress}
161
+ onChange={(e) => setPrimarySmtpAddress(e.target.value)}
162
+ placeholder="support@example.com"
163
+ />
164
+ </FormField>
165
+ )}
166
+
167
+ <FormField label="Display name" htmlFor="displayName">
168
+ <input
169
+ id="displayName"
170
+ type="text"
171
+ className={INPUT_CLASS}
172
+ value={displayName}
173
+ onChange={(e) => setDisplayName(e.target.value)}
174
+ placeholder="Support"
175
+ />
176
+ </FormField>
177
+
178
+ <FormField label="Owner user uid (optional)" htmlFor="ownerUserUid">
179
+ <input
180
+ id="ownerUserUid"
181
+ type="text"
182
+ className={INPUT_CLASS}
183
+ value={ownerUserUid}
184
+ onChange={(e) => setOwnerUserUid(e.target.value)}
185
+ placeholder="Leave blank for a shared mailbox"
186
+ />
187
+ </FormField>
188
+
189
+ <FormField label="Timezone" htmlFor="timezone">
190
+ <input
191
+ id="timezone"
192
+ type="text"
193
+ className={INPUT_CLASS}
194
+ value={timezone}
195
+ onChange={(e) => setTimezone(e.target.value)}
196
+ placeholder="America/Los_Angeles"
197
+ />
198
+ </FormField>
199
+
200
+ <FormField label="Quota (GB)" htmlFor="quotaGb">
201
+ <input
202
+ id="quotaGb"
203
+ type="number"
204
+ min={1}
205
+ step={1}
206
+ className={INPUT_CLASS}
207
+ value={quotaGb}
208
+ onChange={(e) => {
209
+ quotaTouched.current = true;
210
+ setQuotaGb(Number(e.target.value));
211
+ }}
212
+ />
213
+ </FormField>
214
+
215
+ <label className="flex items-center gap-2 text-sm mb-4">
216
+ <input type="checkbox" checked={isResource} onChange={(e) => setIsResource(e.target.checked)} />
217
+ This is a resource mailbox (a bookable room or piece of equipment)
218
+ </label>
219
+
220
+ {isResource && (
221
+ <div className="border border-border rounded-sm p-4 mb-4 flex flex-col gap-4">
222
+ <FormField label="Resource type" htmlFor="resourceType">
223
+ <select
224
+ id="resourceType"
225
+ className={SELECT_CLASS}
226
+ value={resourceType}
227
+ onChange={(e) => setResourceType(e.target.value as "room" | "equipment")}
228
+ >
229
+ <option value="room">Room</option>
230
+ <option value="equipment">Equipment</option>
231
+ </select>
232
+ </FormField>
233
+
234
+ <FormField label="Capacity (optional)" htmlFor="resourceCapacity">
235
+ <input
236
+ id="resourceCapacity"
237
+ type="number"
238
+ min={0}
239
+ step={1}
240
+ className={INPUT_CLASS}
241
+ value={resourceCapacity}
242
+ onChange={(e) => setResourceCapacity(e.target.value)}
243
+ placeholder="Informational only"
244
+ />
245
+ </FormField>
246
+
247
+ <label className="flex items-center gap-2 text-sm">
248
+ <input
249
+ type="checkbox"
250
+ checked={autoAcceptBookings}
251
+ onChange={(e) => setAutoAcceptBookings(e.target.checked)}
252
+ />
253
+ Automatically accept booking requests
254
+ </label>
255
+
256
+ <label className="flex items-center gap-2 text-sm">
257
+ <input
258
+ type="checkbox"
259
+ checked={allowConflicts}
260
+ onChange={(e) => setAllowConflicts(e.target.checked)}
261
+ />
262
+ Allow conflicting bookings (skip conflict checking entirely)
263
+ </label>
264
+
265
+ <FormField label="Booking window, in days (optional)" htmlFor="bookingWindowDays">
266
+ <input
267
+ id="bookingWindowDays"
268
+ type="number"
269
+ min={0}
270
+ step={1}
271
+ className={INPUT_CLASS}
272
+ value={bookingWindowDays}
273
+ onChange={(e) => setBookingWindowDays(e.target.value)}
274
+ placeholder="No limit"
275
+ />
276
+ </FormField>
277
+
278
+ <FormField label="Maximum duration, in minutes (optional)" htmlFor="maxDurationMinutes">
279
+ <input
280
+ id="maxDurationMinutes"
281
+ type="number"
282
+ min={0}
283
+ step={1}
284
+ className={INPUT_CLASS}
285
+ value={maxDurationMinutes}
286
+ onChange={(e) => setMaxDurationMinutes(e.target.value)}
287
+ placeholder="No limit"
288
+ />
289
+ </FormField>
290
+ </div>
291
+ )}
292
+
293
+ <div className="flex gap-3 mt-2">
294
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
295
+ {submitLabel}
296
+ </Button>
297
+ {cancelHref && (
298
+ <a href={cancelHref}>
299
+ <Button type="button" variant="secondary" className="!w-auto">
300
+ Cancel
301
+ </Button>
302
+ </a>
303
+ )}
304
+ </div>
305
+ </form>
306
+ </>
307
+ );
308
+ }
@@ -0,0 +1,152 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, 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
+ /** Unrounded, so a quota that isn't a whole number of hundredths of a GB (say 4 MB) is shown as it is. */
17
+ function toGb(bytes: number): string {
18
+ return String(bytes / GB);
19
+ }
20
+
21
+ /** The form's values as they were loaded or last saved. */
22
+ interface Baseline {
23
+ defaultQuotaGb: string;
24
+ autoProvisionEnabled: boolean;
25
+ autoProvisionQuotaGb: string;
26
+ }
27
+
28
+ export interface MailboxPolicyFormProps {
29
+ policy: MailboxPolicy;
30
+ onChange: (policy: MailboxPolicy) => void;
31
+ /** Told whether the form has edits that haven't been saved. */
32
+ onDirtyChange?: (dirty: boolean) => void;
33
+ }
34
+
35
+ /** The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. */
36
+ export default function MailboxPolicyForm({ policy, onChange, onDirtyChange }: MailboxPolicyFormProps) {
37
+ const [defaultQuotaGb, setDefaultQuotaGb] = useState(toGb(policy.defaultQuotaBytes));
38
+ const [autoProvisionEnabled, setAutoProvisionEnabled] = useState(policy.autoProvisionEnabled);
39
+ const [autoProvisionQuotaGb, setAutoProvisionQuotaGb] = useState(toGb(policy.autoProvisionQuotaBytes));
40
+ const [error, setError] = useState<string | null>(null);
41
+ const [saving, setSaving] = useState(false);
42
+ const [saved, setSaved] = useState(false);
43
+
44
+ // Edits are unsaved until they match what was loaded or last saved.
45
+ const [baseline, setBaseline] = useState<Baseline>({ defaultQuotaGb, autoProvisionEnabled, autoProvisionQuotaGb });
46
+ const dirty: boolean =
47
+ defaultQuotaGb !== baseline.defaultQuotaGb ||
48
+ autoProvisionEnabled !== baseline.autoProvisionEnabled ||
49
+ autoProvisionQuotaGb !== baseline.autoProvisionQuotaGb;
50
+ useEffect(() => onDirtyChange?.(dirty), [dirty]);
51
+
52
+ async function handleSubmit(e: FormEvent) {
53
+ e.preventDefault();
54
+ setError(null);
55
+ setSaved(false);
56
+ // Only fields the administrator changed are sent, so a quota left alone keeps its exact stored bytes.
57
+ const patch: Partial<MailboxPolicy> = {};
58
+ for (const [field, text, base] of [
59
+ ["defaultQuotaBytes", defaultQuotaGb, baseline.defaultQuotaGb],
60
+ ["autoProvisionQuotaBytes", autoProvisionQuotaGb, baseline.autoProvisionQuotaGb],
61
+ ] as const) {
62
+ if (text === base) {
63
+ continue;
64
+ }
65
+ const bytes: number = Math.round(Number(text) * GB);
66
+ if (!(bytes >= 1)) {
67
+ setError("Quotas must be more than 0 GB.");
68
+ return;
69
+ }
70
+ patch[field] = bytes;
71
+ }
72
+ if (autoProvisionEnabled !== baseline.autoProvisionEnabled) {
73
+ patch.autoProvisionEnabled = autoProvisionEnabled;
74
+ }
75
+ const current: Baseline = { defaultQuotaGb, autoProvisionEnabled, autoProvisionQuotaGb };
76
+ if (Object.keys(patch).length === 0) {
77
+ setSaved(true);
78
+ return;
79
+ }
80
+ setSaving(true);
81
+ try {
82
+ const updated = await updateMailboxPolicy(patch);
83
+ onChange(updated);
84
+ setBaseline(current);
85
+ setSaved(true);
86
+ } catch (err) {
87
+ setError(err instanceof ApiRequestError ? err.message : "Could not save the mailbox policy.");
88
+ } finally {
89
+ setSaving(false);
90
+ }
91
+ }
92
+
93
+ return (
94
+ <div className="max-w-xl">
95
+ <h2 className="text-lg font-bold uppercase tracking-wide mb-1">Mailboxes</h2>
96
+ <p className="text-sm text-text-muted mb-5">Defaults for new mailboxes on this server.</p>
97
+
98
+ {error && <Alert>{error}</Alert>}
99
+ {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
100
+
101
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
102
+ <label className="flex flex-col gap-1.5 text-sm">
103
+ <span className="font-semibold">Default quota (GB)</span>
104
+ <input
105
+ aria-label="Default quota (GB)"
106
+ type="number"
107
+ min={0}
108
+ step="any"
109
+ className={INPUT_CLASS}
110
+ value={defaultQuotaGb}
111
+ onChange={(e) => setDefaultQuotaGb(e.target.value)}
112
+ />
113
+ <span className="text-xs text-text-muted">The storage a mailbox starts with when an administrator creates it.</span>
114
+ </label>
115
+ <label className="flex items-start gap-2 text-sm">
116
+ <input
117
+ type="checkbox"
118
+ className="mt-1"
119
+ checked={autoProvisionEnabled}
120
+ onChange={(e) => setAutoProvisionEnabled(e.target.checked)}
121
+ />
122
+ <span>
123
+ <span className="font-semibold block">Let people create their own mailbox</span>
124
+ <span className="text-xs text-text-muted">
125
+ Someone who signs in without a mailbox can create one, using their username on one of this
126
+ server&rsquo;s verified domains.
127
+ </span>
128
+ </span>
129
+ </label>
130
+ {autoProvisionEnabled && (
131
+ <label className="flex flex-col gap-1.5 text-sm">
132
+ <span className="font-semibold">Quota for self-created mailboxes (GB)</span>
133
+ <input
134
+ aria-label="Quota for self-created mailboxes (GB)"
135
+ type="number"
136
+ min={0}
137
+ step="any"
138
+ className={INPUT_CLASS}
139
+ value={autoProvisionQuotaGb}
140
+ onChange={(e) => setAutoProvisionQuotaGb(e.target.value)}
141
+ />
142
+ </label>
143
+ )}
144
+ <div>
145
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
146
+ Save
147
+ </Button>
148
+ </div>
149
+ </form>
150
+ </div>
151
+ );
152
+ }