@rapidmx/web-client 0.4.0 → 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 (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -2
@@ -25,11 +25,12 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
25
25
  const [loading, setLoading] = useState(true);
26
26
  const [error, setError] = useState(null);
27
27
  const [verifying, setVerifying] = useState(false);
28
+ const [verifyError, setVerifyError] = useState(null);
28
29
  const [copied, setCopied] = useState(false);
29
30
  function reload(id) {
30
31
  setLoading(true);
31
32
  setError(null);
32
- Promise.all([getDomain(id), getDnsSetup(id)])
33
+ return Promise.all([getDomain(id), getDnsSetup(id)])
33
34
  .then(([d, checks]) => {
34
35
  setDomain(d);
35
36
  setDnsSetup(checks);
@@ -39,18 +40,23 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
39
40
  .finally(() => setLoading(false));
40
41
  }
41
42
  useEffect(() => {
42
- reload(uid);
43
+ void reload(uid);
43
44
  }, [uid]);
44
- // Only ever invoked from the "Verify now" button below, which only renders once `domain` is loaded.
45
+ // Only ever invoked from the "Verify now" button below, which only renders once `domain` is loaded. Any failure -
46
+ // verifying, or refreshing afterwards - is shown next to the button with the panel kept, so it can be retried
47
+ // (e.g. once a DNS record has propagated) instead of the whole panel being replaced by an error.
45
48
  async function handleVerify() {
46
49
  setVerifying(true);
47
- setError(null);
50
+ setVerifyError(null);
48
51
  try {
49
52
  await verifyDomain(domain.uid);
50
- reload(domain.uid);
53
+ const [d, checks] = await Promise.all([getDomain(domain.uid), getDnsSetup(domain.uid)]);
54
+ setDomain(d);
55
+ setDnsSetup(checks);
56
+ onLoaded?.(d);
51
57
  }
52
58
  catch (err) {
53
- setError(err instanceof ApiRequestError ? err.message : "Could not verify this domain.");
59
+ setVerifyError(err instanceof ApiRequestError ? err.message : "Could not verify this domain.");
54
60
  }
55
61
  finally {
56
62
  setVerifying(false);
@@ -73,5 +79,5 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
73
79
  return _jsx(Alert, { children: error ?? "Domain not found." });
74
80
  }
75
81
  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))) })] }) })] }))] }));
82
+ 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" })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "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
83
  }
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useState } from "react";
6
+ import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { updateEncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
@@ -28,11 +28,15 @@ export function isEncryptionEnabled(policy) {
28
28
  return TIERS.some((tier) => policy[tier.key] !== "prohibited");
29
29
  }
30
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 }) {
31
+ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange }) {
32
32
  const [values, setValues] = useState(policy);
33
33
  const [error, setError] = useState(null);
34
34
  const [saving, setSaving] = useState(false);
35
35
  const [saved, setSaved] = useState(false);
36
+ // Edits are unsaved until they match what was loaded or last saved.
37
+ const [savedValues, setSavedValues] = useState(policy);
38
+ const dirty = TIERS.some((tier) => values[tier.key] !== savedValues[tier.key]);
39
+ useEffect(() => onDirtyChange?.(dirty), [dirty]);
36
40
  async function handleSubmit(e) {
37
41
  e.preventDefault();
38
42
  setError(null);
@@ -41,6 +45,7 @@ export default function EncryptionPolicyForm({ policy, onChange }) {
41
45
  try {
42
46
  const updated = await updateEncryptionPolicy(values);
43
47
  onChange(updated);
48
+ setSavedValues(values);
44
49
  setSaved(true);
45
50
  }
46
51
  catch (err) {
@@ -69,6 +69,12 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
69
69
  setError("A display name is required.");
70
70
  return;
71
71
  }
72
+ // restapi refuses these (400): a display name shown in From must not look like an address. The fullwidth and
73
+ // small look-alike @ signs are refused too (restapi's `AT_SIGN_LIKE`).
74
+ if (/[@@﹫\r\n]/.test(displayName)) {
75
+ setError("A display name can't contain \"@\" (or a look-alike) or line breaks.");
76
+ return;
77
+ }
72
78
  setSaving(true);
73
79
  try {
74
80
  // ownerUserUid left blank creates a true ownerless shared mailbox (e.g. support@example.com);
@@ -3,42 +3,64 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useState } from "react";
6
+ import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { updateMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
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
12
  const GB = 1000000000;
13
+ /** Unrounded, so a quota that isn't a whole number of hundredths of a GB (say 4 MB) is shown as it is. */
13
14
  function toGb(bytes) {
14
- return String(Math.round((bytes / GB) * 100) / 100);
15
+ return String(bytes / GB);
15
16
  }
16
17
  /** The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. */
17
- export default function MailboxPolicyForm({ policy, onChange }) {
18
+ export default function MailboxPolicyForm({ policy, onChange, onDirtyChange }) {
18
19
  const [defaultQuotaGb, setDefaultQuotaGb] = useState(toGb(policy.defaultQuotaBytes));
19
20
  const [autoProvisionEnabled, setAutoProvisionEnabled] = useState(policy.autoProvisionEnabled);
20
21
  const [autoProvisionQuotaGb, setAutoProvisionQuotaGb] = useState(toGb(policy.autoProvisionQuotaBytes));
21
22
  const [error, setError] = useState(null);
22
23
  const [saving, setSaving] = useState(false);
23
24
  const [saved, setSaved] = useState(false);
25
+ // Edits are unsaved until they match what was loaded or last saved.
26
+ const [baseline, setBaseline] = useState({ defaultQuotaGb, autoProvisionEnabled, autoProvisionQuotaGb });
27
+ const dirty = defaultQuotaGb !== baseline.defaultQuotaGb ||
28
+ autoProvisionEnabled !== baseline.autoProvisionEnabled ||
29
+ autoProvisionQuotaGb !== baseline.autoProvisionQuotaGb;
30
+ useEffect(() => onDirtyChange?.(dirty), [dirty]);
24
31
  async function handleSubmit(e) {
25
32
  e.preventDefault();
26
33
  setError(null);
27
34
  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.");
35
+ // Only fields the administrator changed are sent, so a quota left alone keeps its exact stored bytes.
36
+ const patch = {};
37
+ for (const [field, text, base] of [
38
+ ["defaultQuotaBytes", defaultQuotaGb, baseline.defaultQuotaGb],
39
+ ["autoProvisionQuotaBytes", autoProvisionQuotaGb, baseline.autoProvisionQuotaGb],
40
+ ]) {
41
+ if (text === base) {
42
+ continue;
43
+ }
44
+ const bytes = Math.round(Number(text) * GB);
45
+ if (!(bytes >= 1)) {
46
+ setError("Quotas must be more than 0 GB.");
47
+ return;
48
+ }
49
+ patch[field] = bytes;
50
+ }
51
+ if (autoProvisionEnabled !== baseline.autoProvisionEnabled) {
52
+ patch.autoProvisionEnabled = autoProvisionEnabled;
53
+ }
54
+ const current = { defaultQuotaGb, autoProvisionEnabled, autoProvisionQuotaGb };
55
+ if (Object.keys(patch).length === 0) {
56
+ setSaved(true);
32
57
  return;
33
58
  }
34
59
  setSaving(true);
35
60
  try {
36
- const updated = await updateMailboxPolicy({
37
- defaultQuotaBytes: Math.round(defaultQuota * GB),
38
- autoProvisionEnabled,
39
- autoProvisionQuotaBytes: Math.round(autoQuota * GB),
40
- });
61
+ const updated = await updateMailboxPolicy(patch);
41
62
  onChange(updated);
63
+ setBaseline(current);
42
64
  setSaved(true);
43
65
  }
44
66
  catch (err) {