@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
@@ -19,7 +19,7 @@ export default function MessageDetailPage(props) {
19
19
  return (_jsx(MailShell, { ...props, children: _jsx(MessageDetailContent, { uid: props.params.uid }) }));
20
20
  }
21
21
  function MessageDetailContent({ uid }) {
22
- const { folders, mailboxUid } = useMailShell();
22
+ const { mailboxFolders, mailboxUid } = useMailShell();
23
23
  const [message, setMessage] = useState(null);
24
24
  const [loading, setLoading] = useState(true);
25
25
  const [error, setError] = useState(null);
@@ -48,6 +48,10 @@ function MessageDetailContent({ uid }) {
48
48
  if (error || !message) {
49
49
  return _jsx(Alert, { children: error ?? "Message not found." });
50
50
  }
51
+ // Looked up by the message's own mailboxUid, not the shell's ambient `mailboxUid` - the mobile detail
52
+ // route reaches here from an aggregate ("All Inboxes" etc.) list row too, where there's no single
53
+ // selected mailbox to fall back to.
54
+ const folders = mailboxFolders.find((mf) => mf.mailbox.uid === message.mailboxUid)?.folders ?? [];
51
55
  const backHref = `/?mailboxUid=${encodeURIComponent(message.mailboxUid)}&folderUid=${encodeURIComponent(message.folderUid)}`;
52
56
  const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
53
57
  const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { addMasterKeyWrap, checkSignEnrollmentStatus, findActivePublicKey, getEscrowInfo, getKeyVault, rekey, removeMasterKeyWrap, startSignEnrollment, } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
9
9
  import { SIGNING_PRIVATE_KEY_AAD_PURPOSE, destroyUnlockedKeys, getUnlockedKeys, unlockWithPassword, } from "@rapidmx/react-shared/crypto/keySession.js";
10
10
  import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
11
+ import { LOCAL_INDEX_SIZE_OPTIONS, getDefaultLocalIndexByteBudget, getLocalIndexByteBudget, setLocalIndexByteBudget, } from "../../../shared/search/localIndexSizePreference.js";
11
12
  import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
12
13
  import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
13
14
  import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
@@ -16,6 +17,7 @@ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-sh
16
17
  import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
17
18
  import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
18
19
  import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
20
+ import { destroyLocalIndex } from "../../../shared/search/localIndexRpcClient.js";
19
21
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
20
22
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
21
23
  const MIN_PASSWORD_LENGTH = 8;
@@ -69,11 +71,14 @@ function EncryptionContent() {
69
71
  // invalidates every other unlock method, which the copy needs to say plainly.
70
72
  const [recoveryCodesReason, setRecoveryCodesReason] = useState("regenerate");
71
73
  const [codesSaved, setCodesSaved] = useState(false);
74
+ const [codesCopied, setCodesCopied] = useState(false);
72
75
  const [rotationPassword, setRotationPassword] = useState("");
73
76
  const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
74
77
  const [rotating, setRotating] = useState(false);
75
78
  const [destroyed, setDestroyed] = useState(false);
76
79
  const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
80
+ const [localIndexByteBudget, setLocalIndexByteBudgetState] = useState(() => getLocalIndexByteBudget());
81
+ const localIndexDefaultByteBudgetLabel = LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())?.label ?? "500 MB";
77
82
  // `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
78
83
  // enrollment issues, the server has installed a new signing key that fetch never saw. Only this
79
84
  // page refetches (via `getMailbox()`) to notice; `null` means "no fresher data yet, use mailbox.keys".
@@ -164,6 +169,11 @@ function EncryptionContent() {
164
169
  setIdleTimeoutMinutes(minutes);
165
170
  setIdleTimeoutMinutesState(minutes);
166
171
  }
172
+ function handleLocalIndexByteBudgetChange(e) {
173
+ const bytes = Number(e.target.value);
174
+ setLocalIndexByteBudget(bytes);
175
+ setLocalIndexByteBudgetState(bytes);
176
+ }
167
177
  function loadVault() {
168
178
  return getKeyVault(mailboxUid)
169
179
  .then(setVault)
@@ -243,6 +253,18 @@ function EncryptionContent() {
243
253
  setRegenerating(false);
244
254
  }
245
255
  }
256
+ async function handleCopyCodes() {
257
+ try {
258
+ // Only reachable via the button below, which never renders while newRecoveryCodes is null.
259
+ await navigator.clipboard.writeText(newRecoveryCodes.join("\n"));
260
+ setCodesCopied(true);
261
+ setTimeout(() => setCodesCopied(false), 2000);
262
+ }
263
+ catch {
264
+ // Clipboard access can be denied by the browser - the codes are still selectable/copyable by
265
+ // hand from the list below.
266
+ }
267
+ }
246
268
  /**
247
269
  * Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
248
270
  * comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
@@ -313,6 +335,12 @@ function EncryptionContent() {
313
335
  }
314
336
  function handleDestroyKeysNow() {
315
337
  destroyUnlockedKeys(mailboxUid);
338
+ // Spec §11: the Tier 2 local index MUST be destroyed on the same events that destroy unlocked
339
+ // keys. Not awaited - this page's own "keys removed" confirmation shouldn't wait on it, and
340
+ // destroyLocalIndex() never throws either way. Reached directly here (rather than relying on
341
+ // LocalIndexLifecycle.tsx's polling, which only runs while Mail's own MailShell is mounted) since
342
+ // this button lives on the Settings page, which never mounts that component.
343
+ void destroyLocalIndex(mailboxUid);
316
344
  setDestroyed(true);
317
345
  }
318
346
  if (destroyed) {
@@ -321,10 +349,10 @@ function EncryptionContent() {
321
349
  if (newRecoveryCodes) {
322
350
  return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Save your new recovery codes" }), _jsxs("p", { className: "text-sm text-text-muted mb-4", children: [recoveryCodesReason === "rotate"
323
351
  ? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
324
- : "Your old recovery codes no longer work. ", "If you lose your password, these new codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again."] }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-5 font-mono text-sm", children: newRecoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setNewRecoveryCodes(null), className: "!w-auto", children: "Done" })] }) }));
352
+ : "Your old recovery codes no longer work. ", "If you lose your password, these new codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again."] }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-3 font-mono text-sm", children: newRecoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-5", onClick: handleCopyCodes, children: codesCopied ? "Copied" : "Copy codes to clipboard" }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setNewRecoveryCodes(null), className: "!w-auto", children: "Done" })] }) }));
325
353
  }
326
354
  return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl flex flex-col gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Encryption" }), _jsxs("p", { className: "text-sm text-text-muted", children: ["Manage how ", mailbox.displayName, " unlocks its encryption keys on this and other devices."] })] }), loadError && _jsx(Alert, { children: loadError }), actionError && _jsx(Alert, { children: actionError }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Encryption keys" }), displayedKeys.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1 text-sm", children: displayedKeys.map((key) => (_jsxs("li", { className: "font-mono text-xs", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", key.fingerprint, key.revokedAt && _jsx("span", { className: "text-danger", children: " (revoked)" })] }, key.fingerprint))) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No keys enrolled yet." }))] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Digital signatures" }), activeSigningKey ? (_jsx("p", { className: "text-sm text-text-muted", children: "Enabled \u2014 outgoing mail from this mailbox is signed with a publicly-trusted certificate." })) : signingStatus === "pending" ? (_jsx("p", { className: "text-sm text-text-muted", children: "Requested \u2014 a public certificate authority issues this via an automated email exchange, which can take a few minutes. You can leave this page; it finishes in the background and takes effect automatically once issued." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Lets recipients verify that mail from this mailbox is genuinely from you. Optional \u2014 encryption already works without it." }), signingError && _jsx(Alert, { children: signingError }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: signingStatus === "enrolling", disabled: signingStatus === "enrolling", onClick: handleEnrollSigning, children: "Enable digital signatures" })] }))] }), mailbox.escrowScopeId && (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Escrow" }), escrowError && _jsx(Alert, { children: escrowError }), hasEscrowWrap ? (_jsx("p", { className: "text-sm text-text-muted", children: "This mailbox is under legal/compliance escrow \u2014 an authorized holder in your organization can recover its encrypted mail if needed. This does not weaken protection against anyone else." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Your organization has assigned this mailbox to an escrow scope, but nothing has been protected yet \u2014 an authorized holder cannot recover this mailbox's encrypted mail until you complete this step. This does not weaken protection against anyone else." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: wrappingEscrow, disabled: wrappingEscrow, onClick: handleWrapEscrow, children: "Add escrow protection" })] }))] })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Unlock methods" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Removing a method here stops it from being usable to unlock this mailbox going forward, but it is ", _jsx("strong", { children: "not" }), " full revocation \u2014 anyone who already captured a wrapped copy and knows its secret could still use it. For real revocation (e.g. after a lost device), rotate your keys entirely below instead."] }), vault && vault.masterKeyWraps.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-2", children: [...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
327
355
  const key = `${wrap.method}:${wrap.methodId ?? ""}`;
328
356
  return (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsx("span", { children: METHOD_LABELS[wrap.method] ?? wrap.method }), wrap.method !== "escrow" && (_jsx(Button, { type: "button", variant: "text", className: "!w-auto text-danger", loading: removingMethod === key, disabled: removingMethod !== null, onClick: () => handleRemove(wrap), children: "Remove" }))] }, key));
329
- }) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No unlock methods on file." }))] }), _jsxs("form", { onSubmit: handleAddPassword, className: "flex flex-col gap-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Add a password" }), _jsx("input", { type: "password", "aria-label": "New password", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: newPassword, onChange: (e) => setNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmNewPassword, onChange: (e) => setConfirmNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: addingPassword, disabled: addingPassword, className: "!w-auto", children: "Add password" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Recovery codes" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Regenerating replaces all of your existing recovery codes \u2014 old ones stop working immediately." }), _jsx(Button, { type: "button", variant: "secondary", loading: regenerating, disabled: regenerating, onClick: handleRegenerateRecoveryCodes, className: "!w-auto", children: "Regenerate recovery codes" })] }), _jsxs("form", { onSubmit: handleRotateKeys, className: "flex flex-col gap-2 border-t border-border pt-6", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Rotate keys" }), _jsx("p", { className: "text-xs text-text-muted mb-1", children: "Real revocation, for when a device or an unlock method may have been compromised. Re-protects your existing encryption key under a brand new master key - your signing/ encryption keypair itself doesn\u2019t change, so mail you\u2019ve already sent or received still decrypts normally. Every current unlock method (password, recovery codes, and anything else on file) stops working immediately; you\u2019ll set a new password and get new recovery codes below." }), _jsx("input", { type: "password", "aria-label": "New password for rotated keys", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationPassword, onChange: (e) => setRotationPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password for rotated keys", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationConfirmPassword, onChange: (e) => setRotationConfirmPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto text-danger", loading: rotating, disabled: rotating, children: "Rotate keys now" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Session timeout" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Automatically destroys your unlocked keys on this device after this much time with no activity anywhere in the app - not just Mail or Settings. Applies the next time you open or reload the app." }), _jsx("select", { "aria-label": "Session timeout", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: idleTimeoutMinutes, onChange: handleIdleTimeoutChange, children: IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (_jsx("option", { value: minutes, children: idleTimeoutLabel(minutes) }, minutes))) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "This session" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Removes your unlocked keys from this browser tab\u2019s memory right now, without affecting any other device. You\u2019ll be asked to unlock again the next time you read or send encrypted mail here." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: handleDestroyKeysNow, children: "Destroy keys on this device now" })] })] }) }));
357
+ }) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No unlock methods on file." }))] }), _jsxs("form", { onSubmit: handleAddPassword, className: "flex flex-col gap-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Add a password" }), _jsx("input", { type: "password", "aria-label": "New password", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: newPassword, onChange: (e) => setNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmNewPassword, onChange: (e) => setConfirmNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: addingPassword, disabled: addingPassword, className: "!w-auto", children: "Add password" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Recovery codes" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Regenerating replaces all of your existing recovery codes \u2014 old ones stop working immediately." }), _jsx(Button, { type: "button", variant: "secondary", loading: regenerating, disabled: regenerating, onClick: handleRegenerateRecoveryCodes, className: "!w-auto", children: "Regenerate recovery codes" })] }), _jsxs("form", { onSubmit: handleRotateKeys, className: "flex flex-col gap-2 border-t border-border pt-6", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Rotate keys" }), _jsx("p", { className: "text-xs text-text-muted mb-1", children: "Real revocation, for when a device or an unlock method may have been compromised. Re-protects your existing encryption key under a brand new master key - your signing/ encryption keypair itself doesn\u2019t change, so mail you\u2019ve already sent or received still decrypts normally. Every current unlock method (password, recovery codes, and anything else on file) stops working immediately; you\u2019ll set a new password and get new recovery codes below." }), _jsx("input", { type: "password", "aria-label": "New password for rotated keys", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationPassword, onChange: (e) => setRotationPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password for rotated keys", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationConfirmPassword, onChange: (e) => setRotationConfirmPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto text-danger", loading: rotating, disabled: rotating, children: "Rotate keys now" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Session timeout" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Automatically destroys your unlocked keys on this device after this much time with no activity anywhere in the app - not just Mail or Settings. Applies the next time you open or reload the app." }), _jsx("select", { "aria-label": "Session timeout", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: idleTimeoutMinutes, onChange: handleIdleTimeoutChange, children: IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (_jsx("option", { value: minutes, children: idleTimeoutLabel(minutes) }, minutes))) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Local search index size" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["How much decrypted mail this device keeps in a local encrypted search index, so recent search results and inbox previews work instantly without contacting the server every time. Defaults to ", localIndexDefaultByteBudgetLabel, " on this device. Applies the next time this mailbox\u2019s index rebuilds (e.g. the next time you unlock it) \u2014 lowering it doesn\u2019t delete anything already indexed elsewhere, it just narrows what this device keeps a local copy of."] }), _jsx("select", { "aria-label": "Local search index size", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: localIndexByteBudget, onChange: handleLocalIndexByteBudgetChange, children: LOCAL_INDEX_SIZE_OPTIONS.map((option) => (_jsx("option", { value: option.bytes, children: option.label }, option.bytes))) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "This session" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Removes your unlocked keys from this browser tab\u2019s memory right now, without affecting any other device. You\u2019ll be asked to unlock again the next time you read or send encrypted mail here." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: handleDestroyKeysNow, children: "Destroy keys on this device now" })] })] }) }));
330
358
  }
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { listMailboxAccess, lookupMailboxOwnerByEmail, removeMailboxAccess, setMailboxAccess, } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
9
+ import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
13
+ import { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
14
+ const ROLE_LABELS = { viewer: "Can view", manager: "Can manage" };
15
+ export default function SettingsSharingPage(props) {
16
+ return (_jsx(SettingsShell, { ...props, active: "sharing", children: _jsx(SharingContent, {}) }));
17
+ }
18
+ /**
19
+ * Manages who else can access whichever mailbox Settings is currently scoped to (the ambient mailbox from
20
+ * `useSettingsShell()`'s own switcher, which already lists every mailbox the caller can reach, shared ones
21
+ * included) - not just for shared/ownerless mailboxes specifically, matching every other settings page's
22
+ * own mailbox-agnostic design. A single grant here already cascades to that mailbox's calendar/contacts/
23
+ * tasks/mail folders (see `@rapidmx/restapi`'s `BaseMailboxAccessRoute` doc comment), so there is nothing
24
+ * else to configure per data type.
25
+ */
26
+ function SharingContent() {
27
+ const { mailboxUid, mailboxes } = useSettingsShell();
28
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
29
+ const [members, setMembers] = useState([]);
30
+ const [status, setStatus] = useState("loading");
31
+ const [error, setError] = useState(null);
32
+ const [emailInput, setEmailInput] = useState("");
33
+ const [newRole, setNewRole] = useState("viewer");
34
+ const [adding, setAdding] = useState(false);
35
+ const [addError, setAddError] = useState(null);
36
+ const [pendingRoleChange, setPendingRoleChange] = useState(null);
37
+ const [removeTarget, setRemoveTarget] = useState(null);
38
+ const [removing, setRemoving] = useState(false);
39
+ function reload() {
40
+ if (!mailboxUid) {
41
+ return;
42
+ }
43
+ setStatus("loading");
44
+ setError(null);
45
+ listMailboxAccess(mailboxUid)
46
+ .then((result) => {
47
+ setMembers(result);
48
+ setStatus("ready");
49
+ })
50
+ .catch((err) => {
51
+ // A 403 here is the live, server-verified "can this caller manage this mailbox's sharing"
52
+ // signal - the same permission threshold (ACLAction.UPDATE) this route itself requires, so
53
+ // no separate probe call is needed to decide which state to render.
54
+ if (err instanceof ApiRequestError && err.status === 403) {
55
+ setStatus("forbidden");
56
+ return;
57
+ }
58
+ setError(err instanceof ApiRequestError ? err.message : "Could not load sharing settings.");
59
+ setStatus("error");
60
+ });
61
+ }
62
+ useEffect(reload, [mailboxUid]);
63
+ async function handleAdd(e) {
64
+ e.preventDefault();
65
+ const email = emailInput.trim();
66
+ if (!email || !mailboxUid) {
67
+ return;
68
+ }
69
+ setAddError(null);
70
+ setAdding(true);
71
+ try {
72
+ const found = await lookupMailboxOwnerByEmail(email);
73
+ if (!found) {
74
+ setAddError("No one found with that email on this platform.");
75
+ return;
76
+ }
77
+ await setMailboxAccess(mailboxUid, found.userUid, newRole);
78
+ setEmailInput("");
79
+ setNewRole("viewer");
80
+ reload();
81
+ }
82
+ catch (err) {
83
+ setAddError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
84
+ }
85
+ finally {
86
+ setAdding(false);
87
+ }
88
+ }
89
+ async function handleRoleChange(userOrRoleId, role) {
90
+ if (!mailboxUid) {
91
+ return;
92
+ }
93
+ setPendingRoleChange(userOrRoleId);
94
+ setError(null);
95
+ try {
96
+ await setMailboxAccess(mailboxUid, userOrRoleId, role);
97
+ reload();
98
+ }
99
+ catch (err) {
100
+ setError(err instanceof ApiRequestError ? err.message : "Could not update this member's role.");
101
+ }
102
+ finally {
103
+ setPendingRoleChange(null);
104
+ }
105
+ }
106
+ function closeRemoveModal() {
107
+ setRemoveTarget(null);
108
+ }
109
+ async function handleRemove() {
110
+ if (!mailboxUid || !removeTarget) {
111
+ return;
112
+ }
113
+ setRemoving(true);
114
+ setError(null);
115
+ try {
116
+ await removeMailboxAccess(mailboxUid, removeTarget.userOrRoleId);
117
+ setRemoveTarget(null);
118
+ reload();
119
+ }
120
+ catch (err) {
121
+ setError(err instanceof ApiRequestError ? err.message : "Could not remove this member.");
122
+ }
123
+ finally {
124
+ setRemoving(false);
125
+ }
126
+ }
127
+ return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl flex flex-col gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Sharing" }), _jsxs("p", { className: "text-sm text-text-muted", children: ["Manage who else can access ", mailbox?.displayName ?? "this mailbox", "\u2019s mail, calendar, contacts, and tasks. Granting access here covers all of it at once - there\u2019s nothing else to configure per data type."] })] }), status === "loading" && _jsx(SkeletonList, { count: 3 }), status === "forbidden" && (_jsx(Alert, { children: "You don\u2019t have permission to manage sharing for this mailbox." })), status === "error" && error && _jsx(Alert, { children: error }), status === "ready" && (_jsxs(_Fragment, { children: [error && _jsx(Alert, { children: error }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "People with access" }), members.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No one else has access to this mailbox yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: members.map((member) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsx("span", { className: "truncate", children: member.userOrRoleId }), _jsxs("span", { className: "flex items-center gap-2 shrink-0", children: [_jsx("select", { "aria-label": `Role for ${member.userOrRoleId}`, className: "text-sm border border-border rounded-sm py-1 px-2 bg-surface", value: member.role, disabled: pendingRoleChange === member.userOrRoleId, onChange: (e) => handleRoleChange(member.userOrRoleId, e.target.value), children: Object.keys(ROLE_LABELS).map((role) => (_jsx("option", { value: role, children: ROLE_LABELS[role] }, role))) }), _jsx(Button, { type: "button", variant: "text", className: "!w-auto text-danger", onClick: () => setRemoveTarget(member), children: "Remove" })] })] }, member.userOrRoleId))) }))] }), _jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Add someone" }), addError && _jsx(Alert, { children: addError }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", "aria-label": "Email address", placeholder: "Email address", className: "flex-1 text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: emailInput, onChange: (e) => setEmailInput(e.target.value), disabled: adding }), _jsx("select", { "aria-label": "Role to grant", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: newRole, onChange: (e) => setNewRole(e.target.value), disabled: adding, children: Object.keys(ROLE_LABELS).map((role) => (_jsx("option", { value: role, children: ROLE_LABELS[role] }, role))) }), _jsx(Button, { type: "submit", loading: adding, disabled: adding, className: "!w-auto", children: "Add" })] })] })] })), _jsxs(Modal, { open: !!removeTarget, onClose: closeRemoveModal, title: "Remove access", children: [_jsxs("p", { className: "text-sm mb-5", children: [removeTarget?.userOrRoleId, " will no longer be able to access ", mailbox?.displayName ?? "this mailbox", "."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: removing, onClick: closeRemoveModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: removing, disabled: removing, onClick: handleRemove, children: "Remove" })] })] })] }) }));
128
+ }
@@ -13,6 +13,7 @@ import TasksSidebar from "../../shared/components/tasks/TasksSidebar.js";
13
13
  import TasksToolbar from "../../shared/components/tasks/TasksToolbar.js";
14
14
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
15
15
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
16
+ import { findWellKnownFolderUid } from "../../shared/mail/findWellKnownFolderUid.js";
16
17
  const INPUT_CLASS = "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
18
  export default function TasksPage(props) {
18
19
  return (_jsx(TasksShell, { ...props, children: _jsx(TasksContent, {}) }));
@@ -42,7 +43,11 @@ const PRIORITY_CLASS = {
42
43
  high: "text-danger",
43
44
  };
44
45
  function TasksContent() {
45
- const { folderUid, mailboxUid, userUid } = useTasksShell();
46
+ const { folderUid, mailboxUid, userUid, mailboxes } = useTasksShell();
47
+ // The quick-add form's chosen mailbox - `undefined` means "the mailbox being viewed".
48
+ const [targetMailboxUid, setTargetMailboxUid] = useState(undefined);
49
+ // Set after a task is added to a different mailbox than the one this list shows.
50
+ const [addedElsewhere, setAddedElsewhere] = useState(null);
46
51
  const [tasks, setTasks] = useState([]);
47
52
  const [loading, setLoading] = useState(true);
48
53
  const [error, setError] = useState(null);
@@ -73,6 +78,10 @@ function TasksContent() {
73
78
  useEffect(() => {
74
79
  void reload();
75
80
  }, [folderUid]);
81
+ useEffect(() => {
82
+ setTargetMailboxUid(undefined);
83
+ setAddedElsewhere(null);
84
+ }, [mailboxUid]);
76
85
  useEffect(() => {
77
86
  if (view.type !== "flagged" || !mailboxUid) {
78
87
  return;
@@ -94,16 +103,29 @@ function TasksContent() {
94
103
  if (!mailboxUid || !folderUid) {
95
104
  return;
96
105
  }
106
+ const target = targetMailboxUid ?? mailboxUid;
97
107
  setCreating(true);
98
108
  try {
109
+ const targetFolderUid = target === mailboxUid ? folderUid : await findWellKnownFolderUid(target, "tasks");
110
+ if (!targetFolderUid) {
111
+ setCreateError("That mailbox has no Tasks folder.");
112
+ return;
113
+ }
99
114
  const created = await createTask({
100
- mailboxUid,
101
- folderUid,
115
+ mailboxUid: target,
116
+ folderUid: targetFolderUid,
102
117
  title: title.trim(),
103
118
  dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
104
119
  priority,
105
120
  });
106
- setTasks((prev) => [...prev, created]);
121
+ if (target === mailboxUid) {
122
+ setAddedElsewhere(null);
123
+ setTasks((prev) => [...prev, created]);
124
+ }
125
+ else {
126
+ // Created in another mailbox - it doesn't belong in this mailbox's list.
127
+ setAddedElsewhere({ mailboxUid: target, title: created.title });
128
+ }
107
129
  setTitle("");
108
130
  setDueDate("");
109
131
  setPriority("normal");
@@ -227,7 +249,7 @@ function TasksContent() {
227
249
  return map;
228
250
  }, [viewFiltered]);
229
251
  const completedTasks = viewFiltered.filter((t) => t.completed);
230
- return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(TasksSidebar, { mailboxUid: mailboxUid, tasks: tasks, userUid: userUid, active: view, onSelect: handleSelectView }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col overflow-y-auto", children: [_jsx(TasksToolbar, { viewMode: viewMode, onViewModeChange: setViewMode, selectedCount: checkedTasks.length, onComplete: handleBulkComplete, onDelete: handleBulkDelete, onAddToMyDay: handleBulkAddToMyDay }), _jsxs("div", { role: "region", "aria-label": "Tasks list", className: "max-w-2xl mx-auto w-full flex flex-col gap-6 p-6", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Tasks" }), error && _jsx(Alert, { children: error }), view.type === "flagged" ? (_jsx(FlaggedEmailList, { loading: flaggedLoading, error: flaggedError, messages: flaggedMessages })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: handleCreate, className: "flex items-center gap-2 bg-surface border border-border rounded-md p-2", children: [_jsx("input", { type: "text", "aria-label": "Add a task", className: `${INPUT_CLASS} flex-1`, value: title, onChange: (e) => setTitle(e.target.value), placeholder: "Add a task" }), _jsx("input", { type: "date", "aria-label": "Due date", className: INPUT_CLASS, value: dueDate, onChange: (e) => setDueDate(e.target.value) }), _jsxs("select", { "aria-label": "Priority", className: INPUT_CLASS, value: priority, onChange: (e) => setPriority(e.target.value), children: [_jsx("option", { value: "low", children: "Low" }), _jsx("option", { value: "normal", children: "Normal" }), _jsx("option", { value: "high", children: "High" })] }), _jsx(Button, { type: "submit", loading: creating, disabled: creating, className: "!w-auto shrink-0", children: "Add" })] }), createError && _jsx(Alert, { children: createError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : viewFiltered.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No tasks yet." })) : viewMode === "grid" ? (_jsx(TaskTable, { tasks: viewFiltered, checkedUids: checkedUids, onToggleChecked: toggleChecked, onToggle: handleToggle, onDelete: handleDelete })) : (_jsxs(_Fragment, { children: [BUCKET_ORDER.map((bucket) => {
252
+ return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(TasksSidebar, { mailboxUid: mailboxUid, tasks: tasks, userUid: userUid, active: view, onSelect: handleSelectView }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col overflow-y-auto", children: [_jsx(TasksToolbar, { viewMode: viewMode, onViewModeChange: setViewMode, selectedCount: checkedTasks.length, onComplete: handleBulkComplete, onDelete: handleBulkDelete, onAddToMyDay: handleBulkAddToMyDay }), _jsxs("div", { role: "region", "aria-label": "Tasks list", className: "max-w-2xl mx-auto w-full flex flex-col gap-6 p-6", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Tasks" }), error && _jsx(Alert, { children: error }), view.type === "flagged" ? (_jsx(FlaggedEmailList, { loading: flaggedLoading, error: flaggedError, messages: flaggedMessages })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: handleCreate, className: "flex items-center gap-2 bg-surface border border-border rounded-md p-2", children: [_jsx("input", { type: "text", "aria-label": "Add a task", className: `${INPUT_CLASS} flex-1`, value: title, onChange: (e) => setTitle(e.target.value), placeholder: "Add a task" }), _jsx("input", { type: "date", "aria-label": "Due date", className: INPUT_CLASS, value: dueDate, onChange: (e) => setDueDate(e.target.value) }), _jsxs("select", { "aria-label": "Priority", className: INPUT_CLASS, value: priority, onChange: (e) => setPriority(e.target.value), children: [_jsx("option", { value: "low", children: "Low" }), _jsx("option", { value: "normal", children: "Normal" }), _jsx("option", { value: "high", children: "High" })] }), mailboxes.length > 1 && (_jsx("select", { "aria-label": "Task mailbox", className: `${INPUT_CLASS} max-w-40`, value: targetMailboxUid ?? mailboxUid ?? "", onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid === userUid ? "" : " (shared)"] }, mb.uid))) })), _jsx(Button, { type: "submit", loading: creating, disabled: creating, className: "!w-auto shrink-0", children: "Add" })] }), createError && _jsx(Alert, { children: createError }), addedElsewhere && (_jsxs("p", { role: "status", className: "text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: ["\u201C", addedElsewhere.title, "\u201D was added to", " ", mailboxes.find((mb) => mb.uid === addedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/tasks?mailboxUid=${encodeURIComponent(addedElsewhere.mailboxUid)}`, className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s tasks" })] })), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : viewFiltered.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No tasks yet." })) : viewMode === "grid" ? (_jsx(TaskTable, { tasks: viewFiltered, checkedUids: checkedUids, onToggleChecked: toggleChecked, onToggle: handleToggle, onDelete: handleDelete })) : (_jsxs(_Fragment, { children: [BUCKET_ORDER.map((bucket) => {
231
253
  const items = grouped.get(bucket);
232
254
  if (items.length === 0) {
233
255
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",
@@ -37,8 +37,9 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@dnd-kit/core": "^6.3.1",
40
+ "@journeyapps/wa-sqlite": "^2.0.4",
40
41
  "@peculiar/x509": "^2.1.0",
41
- "@rapidmx/react-shared": "patch:@rapidmx/react-shared@npm%3A0.2.0#~/.yarn/patches/@rapidmx-react-shared-npm-0.2.0-d6173ce3e5.patch",
42
+ "@rapidmx/react-shared": "^0.4.0",
42
43
  "@tiptap/core": "^3.31.3",
43
44
  "@tiptap/extension-highlight": "^3.31.3",
44
45
  "@tiptap/extension-image": "^3.31.3",