@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
@@ -24,7 +24,7 @@ export default function MessageDetailPage(props: MailShellProps & { params: { ui
24
24
  }
25
25
 
26
26
  function MessageDetailContent({ uid }: { uid: string }) {
27
- const { folders, mailboxUid } = useMailShell();
27
+ const { mailboxFolders, mailboxUid } = useMailShell();
28
28
  const [message, setMessage] = useState<Message | null>(null);
29
29
  const [loading, setLoading] = useState(true);
30
30
  const [error, setError] = useState<string | null>(null);
@@ -58,6 +58,10 @@ function MessageDetailContent({ uid }: { uid: string }) {
58
58
  return <Alert>{error ?? "Message not found."}</Alert>;
59
59
  }
60
60
 
61
+ // Looked up by the message's own mailboxUid, not the shell's ambient `mailboxUid` - the mobile detail
62
+ // route reaches here from an aggregate ("All Inboxes" etc.) list row too, where there's no single
63
+ // selected mailbox to fall back to.
64
+ const folders = mailboxFolders.find((mf) => mf.mailbox.uid === message.mailboxUid)?.folders ?? [];
61
65
  const backHref = `/?mailboxUid=${encodeURIComponent(message.mailboxUid)}&folderUid=${encodeURIComponent(message.folderUid)}`;
62
66
  const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
63
67
  const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
@@ -24,6 +24,12 @@ import {
24
24
  unlockWithPassword,
25
25
  } from "@rapidmx/react-shared/crypto/keySession.js";
26
26
  import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
27
+ import {
28
+ LOCAL_INDEX_SIZE_OPTIONS,
29
+ getDefaultLocalIndexByteBudget,
30
+ getLocalIndexByteBudget,
31
+ setLocalIndexByteBudget,
32
+ } from "../../../shared/search/localIndexSizePreference.js";
27
33
  import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
28
34
  import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
29
35
  import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
@@ -32,6 +38,7 @@ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-sh
32
38
  import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
33
39
  import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
34
40
  import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
41
+ import { destroyLocalIndex } from "../../../shared/search/localIndexRpcClient.js";
35
42
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
36
43
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
37
44
 
@@ -105,6 +112,7 @@ function EncryptionContent() {
105
112
  // invalidates every other unlock method, which the copy needs to say plainly.
106
113
  const [recoveryCodesReason, setRecoveryCodesReason] = useState<"regenerate" | "rotate">("regenerate");
107
114
  const [codesSaved, setCodesSaved] = useState(false);
115
+ const [codesCopied, setCodesCopied] = useState(false);
108
116
 
109
117
  const [rotationPassword, setRotationPassword] = useState("");
110
118
  const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
@@ -112,6 +120,9 @@ function EncryptionContent() {
112
120
 
113
121
  const [destroyed, setDestroyed] = useState(false);
114
122
  const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
123
+ const [localIndexByteBudget, setLocalIndexByteBudgetState] = useState(() => getLocalIndexByteBudget());
124
+ const localIndexDefaultByteBudgetLabel =
125
+ LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())?.label ?? "500 MB";
115
126
 
116
127
  // `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
117
128
  // enrollment issues, the server has installed a new signing key that fetch never saw. Only this
@@ -209,6 +220,12 @@ function EncryptionContent() {
209
220
  setIdleTimeoutMinutesState(minutes);
210
221
  }
211
222
 
223
+ function handleLocalIndexByteBudgetChange(e: React.ChangeEvent<HTMLSelectElement>) {
224
+ const bytes = Number(e.target.value);
225
+ setLocalIndexByteBudget(bytes);
226
+ setLocalIndexByteBudgetState(bytes);
227
+ }
228
+
212
229
  function loadVault() {
213
230
  return getKeyVault(mailboxUid!)
214
231
  .then(setVault)
@@ -288,6 +305,18 @@ function EncryptionContent() {
288
305
  }
289
306
  }
290
307
 
308
+ async function handleCopyCodes() {
309
+ try {
310
+ // Only reachable via the button below, which never renders while newRecoveryCodes is null.
311
+ await navigator.clipboard.writeText(newRecoveryCodes!.join("\n"));
312
+ setCodesCopied(true);
313
+ setTimeout(() => setCodesCopied(false), 2000);
314
+ } catch {
315
+ // Clipboard access can be denied by the browser - the codes are still selectable/copyable by
316
+ // hand from the list below.
317
+ }
318
+ }
319
+
291
320
  /**
292
321
  * Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
293
322
  * comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
@@ -360,6 +389,12 @@ function EncryptionContent() {
360
389
 
361
390
  function handleDestroyKeysNow() {
362
391
  destroyUnlockedKeys(mailboxUid);
392
+ // Spec §11: the Tier 2 local index MUST be destroyed on the same events that destroy unlocked
393
+ // keys. Not awaited - this page's own "keys removed" confirmation shouldn't wait on it, and
394
+ // destroyLocalIndex() never throws either way. Reached directly here (rather than relying on
395
+ // LocalIndexLifecycle.tsx's polling, which only runs while Mail's own MailShell is mounted) since
396
+ // this button lives on the Settings page, which never mounts that component.
397
+ void destroyLocalIndex(mailboxUid!);
363
398
  setDestroyed(true);
364
399
  }
365
400
 
@@ -388,13 +423,16 @@ function EncryptionContent() {
388
423
  If you lose your password, these new codes are the only way to recover your encrypted mail.
389
424
  Each code can be used once. Store them somewhere safe — they will not be shown again.
390
425
  </p>
391
- <ul className="grid grid-cols-2 gap-2 mb-5 font-mono text-sm">
426
+ <ul className="grid grid-cols-2 gap-2 mb-3 font-mono text-sm">
392
427
  {newRecoveryCodes.map((code) => (
393
428
  <li key={code} className="bg-surface-alt rounded-sm py-1.5 px-2 text-center">
394
429
  {code}
395
430
  </li>
396
431
  ))}
397
432
  </ul>
433
+ <Button type="button" variant="secondary" className="!w-auto mb-5" onClick={handleCopyCodes}>
434
+ {codesCopied ? "Copied" : "Copy codes to clipboard"}
435
+ </Button>
398
436
  <label className="flex items-center gap-2 text-sm mb-4">
399
437
  <input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
400
438
  I have saved these recovery codes in a safe place.
@@ -647,6 +685,30 @@ function EncryptionContent() {
647
685
  </select>
648
686
  </div>
649
687
 
688
+ <div>
689
+ <h2 className="text-sm font-semibold mb-2">Local search index size</h2>
690
+ <p className="text-xs text-text-muted mb-3">
691
+ How much decrypted mail this device keeps in a local encrypted search index, so recent
692
+ search results and inbox previews work instantly without contacting the server every
693
+ time. Defaults to {localIndexDefaultByteBudgetLabel} on this device. Applies the next
694
+ time this mailbox&rsquo;s index rebuilds (e.g. the next time you unlock it) — lowering
695
+ it doesn&rsquo;t delete anything already indexed elsewhere, it just narrows what this
696
+ device keeps a local copy of.
697
+ </p>
698
+ <select
699
+ aria-label="Local search index size"
700
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
701
+ value={localIndexByteBudget}
702
+ onChange={handleLocalIndexByteBudgetChange}
703
+ >
704
+ {LOCAL_INDEX_SIZE_OPTIONS.map((option) => (
705
+ <option key={option.bytes} value={option.bytes}>
706
+ {option.label}
707
+ </option>
708
+ ))}
709
+ </select>
710
+ </div>
711
+
650
712
  <div>
651
713
  <h2 className="text-sm font-semibold mb-2">This session</h2>
652
714
  <p className="text-xs text-text-muted mb-3">
@@ -0,0 +1,271 @@
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 {
8
+ MailboxAccessMember,
9
+ MailboxAccessRole,
10
+ listMailboxAccess,
11
+ lookupMailboxOwnerByEmail,
12
+ removeMailboxAccess,
13
+ setMailboxAccess,
14
+ } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
15
+ import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
16
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
18
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
19
+ import { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
20
+
21
+ const ROLE_LABELS: Record<MailboxAccessRole, string> = { viewer: "Can view", manager: "Can manage" };
22
+
23
+ export type SettingsSharingPageProps = Omit<SettingsShellProps, "active">;
24
+
25
+ export default function SettingsSharingPage(props: SettingsSharingPageProps) {
26
+ return (
27
+ <SettingsShell {...props} active="sharing">
28
+ <SharingContent />
29
+ </SettingsShell>
30
+ );
31
+ }
32
+
33
+ type Status = "loading" | "ready" | "forbidden" | "error";
34
+
35
+ /**
36
+ * Manages who else can access whichever mailbox Settings is currently scoped to (the ambient mailbox from
37
+ * `useSettingsShell()`'s own switcher, which already lists every mailbox the caller can reach, shared ones
38
+ * included) - not just for shared/ownerless mailboxes specifically, matching every other settings page's
39
+ * own mailbox-agnostic design. A single grant here already cascades to that mailbox's calendar/contacts/
40
+ * tasks/mail folders (see `@rapidmx/restapi`'s `BaseMailboxAccessRoute` doc comment), so there is nothing
41
+ * else to configure per data type.
42
+ */
43
+ function SharingContent() {
44
+ const { mailboxUid, mailboxes } = useSettingsShell();
45
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
46
+
47
+ const [members, setMembers] = useState<MailboxAccessMember[]>([]);
48
+ const [status, setStatus] = useState<Status>("loading");
49
+ const [error, setError] = useState<string | null>(null);
50
+
51
+ const [emailInput, setEmailInput] = useState("");
52
+ const [newRole, setNewRole] = useState<MailboxAccessRole>("viewer");
53
+ const [adding, setAdding] = useState(false);
54
+ const [addError, setAddError] = useState<string | null>(null);
55
+
56
+ const [pendingRoleChange, setPendingRoleChange] = useState<string | null>(null);
57
+ const [removeTarget, setRemoveTarget] = useState<MailboxAccessMember | null>(null);
58
+ const [removing, setRemoving] = useState(false);
59
+
60
+ function reload() {
61
+ if (!mailboxUid) {
62
+ return;
63
+ }
64
+ setStatus("loading");
65
+ setError(null);
66
+ listMailboxAccess(mailboxUid)
67
+ .then((result) => {
68
+ setMembers(result);
69
+ setStatus("ready");
70
+ })
71
+ .catch((err) => {
72
+ // A 403 here is the live, server-verified "can this caller manage this mailbox's sharing"
73
+ // signal - the same permission threshold (ACLAction.UPDATE) this route itself requires, so
74
+ // no separate probe call is needed to decide which state to render.
75
+ if (err instanceof ApiRequestError && err.status === 403) {
76
+ setStatus("forbidden");
77
+ return;
78
+ }
79
+ setError(err instanceof ApiRequestError ? err.message : "Could not load sharing settings.");
80
+ setStatus("error");
81
+ });
82
+ }
83
+
84
+ useEffect(reload, [mailboxUid]);
85
+
86
+ async function handleAdd(e: FormEvent) {
87
+ e.preventDefault();
88
+ const email = emailInput.trim();
89
+ if (!email || !mailboxUid) {
90
+ return;
91
+ }
92
+ setAddError(null);
93
+ setAdding(true);
94
+ try {
95
+ const found = await lookupMailboxOwnerByEmail(email);
96
+ if (!found) {
97
+ setAddError("No one found with that email on this platform.");
98
+ return;
99
+ }
100
+ await setMailboxAccess(mailboxUid, found.userUid, newRole);
101
+ setEmailInput("");
102
+ setNewRole("viewer");
103
+ reload();
104
+ } catch (err) {
105
+ setAddError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
106
+ } finally {
107
+ setAdding(false);
108
+ }
109
+ }
110
+
111
+ async function handleRoleChange(userOrRoleId: string, role: MailboxAccessRole) {
112
+ if (!mailboxUid) {
113
+ return;
114
+ }
115
+ setPendingRoleChange(userOrRoleId);
116
+ setError(null);
117
+ try {
118
+ await setMailboxAccess(mailboxUid, userOrRoleId, role);
119
+ reload();
120
+ } catch (err) {
121
+ setError(err instanceof ApiRequestError ? err.message : "Could not update this member's role.");
122
+ } finally {
123
+ setPendingRoleChange(null);
124
+ }
125
+ }
126
+
127
+ function closeRemoveModal() {
128
+ setRemoveTarget(null);
129
+ }
130
+
131
+ async function handleRemove() {
132
+ if (!mailboxUid || !removeTarget) {
133
+ return;
134
+ }
135
+ setRemoving(true);
136
+ setError(null);
137
+ try {
138
+ await removeMailboxAccess(mailboxUid, removeTarget.userOrRoleId);
139
+ setRemoveTarget(null);
140
+ reload();
141
+ } catch (err) {
142
+ setError(err instanceof ApiRequestError ? err.message : "Could not remove this member.");
143
+ } finally {
144
+ setRemoving(false);
145
+ }
146
+ }
147
+
148
+ return (
149
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
150
+ <div className="max-w-xl flex flex-col gap-6">
151
+ <div>
152
+ <h1 className="text-lg font-bold tracking-tight mb-1">Sharing</h1>
153
+ <p className="text-sm text-text-muted">
154
+ Manage who else can access {mailbox?.displayName ?? "this mailbox"}&rsquo;s mail, calendar,
155
+ contacts, and tasks. Granting access here covers all of it at once - there&rsquo;s nothing
156
+ else to configure per data type.
157
+ </p>
158
+ </div>
159
+
160
+ {status === "loading" && <SkeletonList count={3} />}
161
+
162
+ {status === "forbidden" && (
163
+ <Alert>You don&rsquo;t have permission to manage sharing for this mailbox.</Alert>
164
+ )}
165
+
166
+ {status === "error" && error && <Alert>{error}</Alert>}
167
+
168
+ {status === "ready" && (
169
+ <>
170
+ {error && <Alert>{error}</Alert>}
171
+
172
+ <div>
173
+ <h2 className="text-sm font-semibold mb-2">People with access</h2>
174
+ {members.length === 0 ? (
175
+ <p className="text-sm text-text-muted">No one else has access to this mailbox yet.</p>
176
+ ) : (
177
+ <ul className="flex flex-col gap-2">
178
+ {members.map((member) => (
179
+ <li
180
+ key={member.userOrRoleId}
181
+ className="flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm"
182
+ >
183
+ <span className="truncate">{member.userOrRoleId}</span>
184
+ <span className="flex items-center gap-2 shrink-0">
185
+ <select
186
+ aria-label={`Role for ${member.userOrRoleId}`}
187
+ className="text-sm border border-border rounded-sm py-1 px-2 bg-surface"
188
+ value={member.role}
189
+ disabled={pendingRoleChange === member.userOrRoleId}
190
+ onChange={(e) =>
191
+ handleRoleChange(member.userOrRoleId, e.target.value as MailboxAccessRole)
192
+ }
193
+ >
194
+ {(Object.keys(ROLE_LABELS) as MailboxAccessRole[]).map((role) => (
195
+ <option key={role} value={role}>
196
+ {ROLE_LABELS[role]}
197
+ </option>
198
+ ))}
199
+ </select>
200
+ <Button
201
+ type="button"
202
+ variant="text"
203
+ className="!w-auto text-danger"
204
+ onClick={() => setRemoveTarget(member)}
205
+ >
206
+ Remove
207
+ </Button>
208
+ </span>
209
+ </li>
210
+ ))}
211
+ </ul>
212
+ )}
213
+ </div>
214
+
215
+ <form onSubmit={handleAdd} className="flex flex-col gap-2">
216
+ <h2 className="text-sm font-semibold">Add someone</h2>
217
+ {addError && <Alert>{addError}</Alert>}
218
+ <div className="flex gap-2">
219
+ <input
220
+ type="email"
221
+ aria-label="Email address"
222
+ placeholder="Email address"
223
+ className="flex-1 text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
224
+ value={emailInput}
225
+ onChange={(e) => setEmailInput(e.target.value)}
226
+ disabled={adding}
227
+ />
228
+ <select
229
+ aria-label="Role to grant"
230
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
231
+ value={newRole}
232
+ onChange={(e) => setNewRole(e.target.value as MailboxAccessRole)}
233
+ disabled={adding}
234
+ >
235
+ {(Object.keys(ROLE_LABELS) as MailboxAccessRole[]).map((role) => (
236
+ <option key={role} value={role}>
237
+ {ROLE_LABELS[role]}
238
+ </option>
239
+ ))}
240
+ </select>
241
+ <Button type="submit" loading={adding} disabled={adding} className="!w-auto">
242
+ Add
243
+ </Button>
244
+ </div>
245
+ </form>
246
+ </>
247
+ )}
248
+
249
+ <Modal open={!!removeTarget} onClose={closeRemoveModal} title="Remove access">
250
+ <p className="text-sm mb-5">
251
+ {removeTarget?.userOrRoleId} will no longer be able to access {mailbox?.displayName ?? "this mailbox"}.
252
+ </p>
253
+ <div className="flex gap-3 justify-end">
254
+ <Button type="button" variant="secondary" className="!w-auto" disabled={removing} onClick={closeRemoveModal}>
255
+ Cancel
256
+ </Button>
257
+ <Button
258
+ type="button"
259
+ className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
260
+ loading={removing}
261
+ disabled={removing}
262
+ onClick={handleRemove}
263
+ >
264
+ Remove
265
+ </Button>
266
+ </div>
267
+ </Modal>
268
+ </div>
269
+ </div>
270
+ );
271
+ }
@@ -22,6 +22,7 @@ import TasksSidebar, { TasksView } from "../../shared/components/tasks/TasksSide
22
22
  import TasksToolbar, { TasksViewMode } from "../../shared/components/tasks/TasksToolbar.js";
23
23
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
24
24
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
25
+ import { findWellKnownFolderUid } from "../../shared/mail/findWellKnownFolderUid.js";
25
26
 
26
27
  const INPUT_CLASS =
27
28
  "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -63,7 +64,11 @@ const PRIORITY_CLASS: Record<TaskPriority, string> = {
63
64
  };
64
65
 
65
66
  function TasksContent() {
66
- const { folderUid, mailboxUid, userUid } = useTasksShell();
67
+ const { folderUid, mailboxUid, userUid, mailboxes } = useTasksShell();
68
+ // The quick-add form's chosen mailbox - `undefined` means "the mailbox being viewed".
69
+ const [targetMailboxUid, setTargetMailboxUid] = useState<string | undefined>(undefined);
70
+ // Set after a task is added to a different mailbox than the one this list shows.
71
+ const [addedElsewhere, setAddedElsewhere] = useState<{ mailboxUid: string; title: string } | null>(null);
67
72
  const [tasks, setTasks] = useState<Task[]>([]);
68
73
  const [loading, setLoading] = useState(true);
69
74
  const [error, setError] = useState<string | null>(null);
@@ -97,6 +102,11 @@ function TasksContent() {
97
102
  void reload();
98
103
  }, [folderUid]);
99
104
 
105
+ useEffect(() => {
106
+ setTargetMailboxUid(undefined);
107
+ setAddedElsewhere(null);
108
+ }, [mailboxUid]);
109
+
100
110
  useEffect(() => {
101
111
  if (view.type !== "flagged" || !mailboxUid) {
102
112
  return;
@@ -119,16 +129,28 @@ function TasksContent() {
119
129
  if (!mailboxUid || !folderUid) {
120
130
  return;
121
131
  }
132
+ const target = targetMailboxUid ?? mailboxUid;
122
133
  setCreating(true);
123
134
  try {
135
+ const targetFolderUid = target === mailboxUid ? folderUid : await findWellKnownFolderUid(target, "tasks");
136
+ if (!targetFolderUid) {
137
+ setCreateError("That mailbox has no Tasks folder.");
138
+ return;
139
+ }
124
140
  const created = await createTask({
125
- mailboxUid,
126
- folderUid,
141
+ mailboxUid: target,
142
+ folderUid: targetFolderUid,
127
143
  title: title.trim(),
128
144
  dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
129
145
  priority,
130
146
  });
131
- setTasks((prev) => [...prev, created]);
147
+ if (target === mailboxUid) {
148
+ setAddedElsewhere(null);
149
+ setTasks((prev) => [...prev, created]);
150
+ } else {
151
+ // Created in another mailbox - it doesn't belong in this mailbox's list.
152
+ setAddedElsewhere({ mailboxUid: target, title: created.title });
153
+ }
132
154
  setTitle("");
133
155
  setDueDate("");
134
156
  setPriority("normal");
@@ -303,11 +325,38 @@ function TasksContent() {
303
325
  <option value="normal">Normal</option>
304
326
  <option value="high">High</option>
305
327
  </select>
328
+ {mailboxes.length > 1 && (
329
+ <select
330
+ aria-label="Task mailbox"
331
+ className={`${INPUT_CLASS} max-w-40`}
332
+ value={targetMailboxUid ?? mailboxUid ?? ""}
333
+ onChange={(e) => setTargetMailboxUid(e.target.value)}
334
+ >
335
+ {mailboxes.map((mb) => (
336
+ <option key={mb.uid} value={mb.uid}>
337
+ {mb.displayName}
338
+ {mb.ownerUserUid === userUid ? "" : " (shared)"}
339
+ </option>
340
+ ))}
341
+ </select>
342
+ )}
306
343
  <Button type="submit" loading={creating} disabled={creating} className="!w-auto shrink-0">
307
344
  Add
308
345
  </Button>
309
346
  </form>
310
347
  {createError && <Alert>{createError}</Alert>}
348
+ {addedElsewhere && (
349
+ <p role="status" className="text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
350
+ &ldquo;{addedElsewhere.title}&rdquo; was added to{" "}
351
+ {mailboxes.find((mb) => mb.uid === addedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
352
+ <a
353
+ href={`/tasks?mailboxUid=${encodeURIComponent(addedElsewhere.mailboxUid)}`}
354
+ className="font-medium text-primary-dark hover:underline"
355
+ >
356
+ View that mailbox&rsquo;s tasks
357
+ </a>
358
+ </p>
359
+ )}
311
360
 
312
361
  {loading ? (
313
362
  <p className="text-sm text-text-muted">Loading&hellip;</p>