@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
@@ -9,6 +9,7 @@ import ContactsShell, { ContactsShellProps } from "../../shared/components/conta
9
9
  import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
10
10
  import ContactForm from "../../shared/components/contacts/ContactForm.js";
11
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
+ import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
12
13
 
13
14
  /**
14
15
  * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/contacts/index.tsx` keeps its
@@ -30,6 +31,9 @@ function ContactDetailContent({ uid }: { uid: string }) {
30
31
  const [contact, setContact] = useState<Contact | null>(null);
31
32
  const [loading, setLoading] = useState(true);
32
33
  const [error, setError] = useState<string | null>(null);
34
+ // Kept apart from the load `error` - a failed delete must leave the contact on screen (with the
35
+ // reason) rather than replacing the whole page with an error as if it had never loaded.
36
+ const [deleteError, setDeleteError] = useState<string | null>(null);
33
37
  const [mode, setMode] = useState<Mode>("view");
34
38
 
35
39
  useEffect(() => {
@@ -46,14 +50,22 @@ function ContactDetailContent({ uid }: { uid: string }) {
46
50
  // rendered once `contact` is already resolved (see the `error || !contact` guard above it), so this
47
51
  // never runs with a stale/absent contact — no redundant null check needed here.
48
52
  async function handleDelete(contact: Contact) {
53
+ setDeleteError(null);
49
54
  try {
50
55
  await deleteContact(contact.uid, contact.version);
56
+ // A deleted contact's pinned signing keys must stop vouching for signatures.
57
+ clearPinnedSignerCache();
51
58
  window.location.href = "/contacts";
52
59
  } catch (err) {
53
- setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
60
+ setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
54
61
  }
55
62
  }
56
63
 
64
+ // After a key change was resolved (or found stale): re-read the contact, keeping the one shown if that fails.
65
+ function handleKeysChanged() {
66
+ getContact(uid).then(setContact, () => undefined);
67
+ }
68
+
57
69
  if (loading) {
58
70
  return <p className="p-8 text-sm text-text-muted">Loading&hellip;</p>;
59
71
  }
@@ -82,7 +94,14 @@ function ContactDetailContent({ uid }: { uid: string }) {
82
94
 
83
95
  return (
84
96
  <div className="p-6">
85
- <ContactDetailPane contact={contact} onEdit={() => setMode("edit")} onDelete={() => handleDelete(contact)} backHref={backHref} />
97
+ {deleteError && <Alert>{deleteError}</Alert>}
98
+ <ContactDetailPane
99
+ contact={contact}
100
+ onEdit={() => setMode("edit")}
101
+ onDelete={() => handleDelete(contact)}
102
+ backHref={backHref}
103
+ onKeysChanged={handleKeysChanged}
104
+ />
86
105
  </div>
87
106
  );
88
107
  }
@@ -22,7 +22,13 @@ import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js
22
22
  import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
23
23
  import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
24
24
  import ContactForm from "../../shared/components/contacts/ContactForm.js";
25
+ import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
26
+ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
25
27
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
28
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
29
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
30
+ import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
31
+ import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
26
32
 
27
33
  const INPUT_CLASS =
28
34
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -30,7 +36,7 @@ const INPUT_CLASS =
30
36
  export default function ContactsPage(props: ContactsShellProps) {
31
37
  return (
32
38
  <ContactsShell {...props}>
33
- <ContactsContent />
39
+ <ContactsContent userUid={props.userUid} />
34
40
  </ContactsShell>
35
41
  );
36
42
  }
@@ -38,22 +44,33 @@ export default function ContactsPage(props: ContactsShellProps) {
38
44
  type Mode = "view" | "edit" | "new";
39
45
  type SortColumn = "name" | "info";
40
46
 
47
+ /** Shown in the Deleted view when the server answered `?deleted=true` with live contacts (see `canViewDeleted`). */
48
+ const DELETED_NOT_PERMITTED_MESSAGE = "You don't have permission to view deleted contacts in this folder.";
49
+
41
50
  function primaryInfo(contact: Contact): string {
42
51
  return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
43
52
  }
44
53
 
54
+ /** How long an export's object URL is kept alive after the click - some browsers are still reading the
55
+ * blob after `click()` returns, so revoking it synchronously can cancel the download. */
56
+ const VCARD_DOWNLOAD_URL_LIFETIME_MS = 60_000;
57
+
45
58
  function downloadTextFile(filename: string, content: string): void {
46
59
  const blob = new Blob([content], { type: "text/vcard" });
47
60
  const url = URL.createObjectURL(blob);
48
61
  const a = document.createElement("a");
49
62
  a.href = url;
50
63
  a.download = filename;
64
+ document.body.appendChild(a);
51
65
  a.click();
52
- URL.revokeObjectURL(url);
66
+ a.remove();
67
+ setTimeout(() => URL.revokeObjectURL(url), VCARD_DOWNLOAD_URL_LIFETIME_MS);
53
68
  }
54
69
 
55
- function ContactsContent() {
70
+ function ContactsContent({ userUid }: { userUid?: string }) {
56
71
  const { folderUid, mailboxUid, mailboxes } = useContactsShell();
72
+ // The new-contact form's Mailbox choices - view-only shares are left out (see writableMailboxes.ts).
73
+ const writableMailboxes = useWritableMailboxes(mailboxes, userUid, mailboxUid);
57
74
  const { openCompose } = useCompose();
58
75
  const isMobile = useIsMobile();
59
76
  const [contacts, setContacts] = useState<Contact[]>([]);
@@ -62,6 +79,9 @@ function ContactsContent() {
62
79
  const [deletedError, setDeletedError] = useState<string | null>(null);
63
80
  const [loading, setLoading] = useState(true);
64
81
  const [error, setError] = useState<string | null>(null);
82
+ // Set when `listAllPages()` stopped at its page cap - the list shown isn't every contact.
83
+ const [truncated, setTruncated] = useState(false);
84
+ const [deletedTruncated, setDeletedTruncated] = useState(false);
65
85
  const [query, setQuery] = useState("");
66
86
  const [view, setView] = useState<ContactsView>({ type: "all" });
67
87
  const [sortColumn, setSortColumn] = useState<SortColumn>("name");
@@ -71,6 +91,7 @@ function ContactsContent() {
71
91
  const [mode, setMode] = useState<Mode>("view");
72
92
  // Set after a contact is created in a different mailbox than the one this list shows.
73
93
  const [savedElsewhere, setSavedElsewhere] = useState<{ mailboxUid: string; displayName: string } | null>(null);
94
+ const [confirmingBulkDelete, setConfirmingBulkDelete] = useState(false);
74
95
 
75
96
  /** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
76
97
  * own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
@@ -79,13 +100,17 @@ function ContactsContent() {
79
100
  function reload(): Promise<void> {
80
101
  if (!folderUid) {
81
102
  setContacts([]);
103
+ setTruncated(false);
82
104
  setLoading(false);
83
105
  return Promise.resolve();
84
106
  }
85
107
  setLoading(true);
86
108
  setError(null);
87
- return listContacts(folderUid, { limit: 500 })
88
- .then(setContacts)
109
+ return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
110
+ .then((result) => {
111
+ setContacts(result.items);
112
+ setTruncated(result.truncated);
113
+ })
89
114
  .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
90
115
  .finally(() => setLoading(false));
91
116
  }
@@ -94,14 +119,56 @@ function ContactsContent() {
94
119
  void reload();
95
120
  }, [folderUid]);
96
121
 
122
+ // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
123
+ // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
124
+ // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
125
+ // access to a folder, so the mailbox-level check below is only a first gate (an owner always passes; anyone else
126
+ // asks the server; unknown - still loading, or the check failed - hides the view), and the Deleted view's own
127
+ // load below detects the server ignoring the filter. `ContactsShell` only renders this with a resolved mailbox,
128
+ // and switching mailboxes is a full page load, so `mailboxUid` never changes under this component.
129
+ const ownsMailbox = mailboxes.some((mb) => mb.uid === mailboxUid && mb.ownerUserUid !== undefined && mb.ownerUserUid === userUid);
130
+ const [delegateCanViewDeleted, setDelegateCanViewDeleted] = useState(false);
131
+ // Resolving a contact's key change needs update rights on the mailbox - `undefined` while unknown.
132
+ const [delegateCanUpdate, setDelegateCanUpdate] = useState<boolean | undefined>(undefined);
133
+ useEffect(() => {
134
+ if (ownsMailbox) {
135
+ return;
136
+ }
137
+ let cancelled = false;
138
+ getMyMailboxAccess(mailboxUid!).then(
139
+ (access) => {
140
+ if (!cancelled) {
141
+ setDelegateCanViewDeleted(access.canDelete && access.canUpdate);
142
+ setDelegateCanUpdate(access.canUpdate);
143
+ }
144
+ },
145
+ () => undefined,
146
+ );
147
+ return () => {
148
+ cancelled = true;
149
+ };
150
+ }, [ownsMailbox]);
151
+ const canViewDeleted = ownsMailbox || delegateCanViewDeleted;
152
+
97
153
  useEffect(() => {
98
154
  if (view.type !== "deleted" || !folderUid) {
99
155
  return;
100
156
  }
101
157
  setDeletedLoading(true);
102
158
  setDeletedError(null);
103
- listDeletedContacts(folderUid, { limit: 500 })
104
- .then(setDeletedContacts)
159
+ listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
160
+ .then((result) => {
161
+ // A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
162
+ // contacts, which must never be listed as deleted.
163
+ if (result.items.some((c) => c.deleted !== true)) {
164
+ setDeletedContacts([]);
165
+ setDeletedTruncated(false);
166
+ setDeletedError(DELETED_NOT_PERMITTED_MESSAGE);
167
+ return;
168
+ }
169
+ setDeletedContacts(result.items);
170
+ setDeletedTruncated(result.truncated);
171
+ })
105
172
  .catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
106
173
  .finally(() => setDeletedLoading(false));
107
174
  // Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
@@ -213,6 +280,8 @@ function ContactsContent() {
213
280
  async function handleDelete(contact: Contact) {
214
281
  try {
215
282
  await deleteContact(contact.uid, contact.version);
283
+ // Trusted signer pins come from contacts - a deleted contact's keys must stop vouching for signatures.
284
+ clearPinnedSignerCache();
216
285
  setSelectedUid(null);
217
286
  setMode("view");
218
287
  void reload();
@@ -222,6 +291,7 @@ function ContactsContent() {
222
291
  }
223
292
 
224
293
  async function handleBulkDelete() {
294
+ setConfirmingBulkDelete(false);
225
295
  let bulkError: string | null = null;
226
296
  for (const contact of checkedContacts) {
227
297
  try {
@@ -230,6 +300,7 @@ function ContactsContent() {
230
300
  bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
231
301
  }
232
302
  }
303
+ clearPinnedSignerCache();
233
304
  setCheckedUids(new Set());
234
305
  await reload();
235
306
  setError(bulkError);
@@ -261,6 +332,7 @@ function ContactsContent() {
261
332
  bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
262
333
  }
263
334
  }
335
+ clearPinnedSignerCache();
264
336
  await reload();
265
337
  setError(bulkError);
266
338
  }
@@ -274,11 +346,14 @@ function ContactsContent() {
274
346
  for (const contact of checkedContacts) {
275
347
  const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
276
348
  try {
277
- await updateContact({ ...contact, categories });
349
+ // Only the changed field - sending the whole fetched contact back includes server-managed
350
+ // fields restapi rejects, and would overwrite any concurrent edit to the other fields.
351
+ await updateContact({ uid: contact.uid, version: contact.version, categories });
278
352
  } catch (err) {
279
353
  bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
280
354
  }
281
355
  }
356
+ clearPinnedSignerCache();
282
357
  await reload();
283
358
  setError(bulkError);
284
359
  }
@@ -304,13 +379,14 @@ function ContactsContent() {
304
379
  bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
305
380
  }
306
381
  }
382
+ clearPinnedSignerCache();
307
383
  await reload();
308
384
  setError(bulkError);
309
385
  }
310
386
 
311
387
  return (
312
388
  <div className="flex-1 flex min-h-0">
313
- <ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} />
389
+ <ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} showDeleted={canViewDeleted} />
314
390
  {/* Hidden on mobile while the "new contact" form (the one form-pane state mobile keeps
315
391
  in-place — see the pane's own comment below) is showing, so the two never compete for the
316
392
  same row's width. Desktop always shows both side by side, unchanged. */}
@@ -325,7 +401,7 @@ function ContactsContent() {
325
401
  allSelectedFavorited={checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite)}
326
402
  onNewContact={handleNew}
327
403
  onEdit={handleToolbarEdit}
328
- onDelete={handleBulkDelete}
404
+ onDelete={() => setConfirmingBulkDelete(true)}
329
405
  onEmail={handleEmail}
330
406
  onToggleFavorite={handleToggleFavorite}
331
407
  onAddCategory={handleAddCategory}
@@ -347,6 +423,14 @@ function ContactsContent() {
347
423
  <Alert>{error}</Alert>
348
424
  </div>
349
425
  )}
426
+ {(isDeletedView ? deletedTruncated : truncated) && (
427
+ <div className="p-3">
428
+ <Alert>
429
+ This folder has more contacts than can be shown at once - only the first {LIST_PAGE_SIZE * MAX_LIST_PAGES} are
430
+ listed.
431
+ </Alert>
432
+ </div>
433
+ )}
350
434
  {deletedError && isDeletedView && (
351
435
  <div className="p-3">
352
436
  <Alert>{deletedError}</Alert>
@@ -446,18 +530,38 @@ function ContactsContent() {
446
530
  <ContactForm
447
531
  mailboxUid={mailboxUid}
448
532
  folderUid={folderUid}
449
- mailboxes={mailboxes}
533
+ mailboxes={writableMailboxes}
450
534
  onSaved={handleSaved}
451
535
  onCancel={handleCancel}
452
536
  />
453
537
  ) : mode === "edit" && selected ? (
454
538
  <ContactForm contact={selected} onSaved={handleSaved} onCancel={handleCancel} />
455
539
  ) : selected ? (
456
- <ContactDetailPane contact={selected} onEdit={() => setMode("edit")} onDelete={() => handleDelete(selected)} />
540
+ <ContactDetailPane
541
+ contact={selected}
542
+ onEdit={() => setMode("edit")}
543
+ onDelete={() => handleDelete(selected)}
544
+ onKeysChanged={() => void reload()}
545
+ canResolveKeys={ownsMailbox || delegateCanUpdate}
546
+ />
457
547
  ) : (
458
548
  <p className="text-sm text-text-muted">Select a contact, or create a new one.</p>
459
549
  )}
460
550
  </div>
551
+ <Modal open={confirmingBulkDelete} onClose={() => setConfirmingBulkDelete(false)} title="Delete contacts">
552
+ <p className="text-sm mb-5">
553
+ Delete {checkedContacts.length} selected {checkedContacts.length === 1 ? "contact" : "contacts"}? They
554
+ move to Deleted contacts.
555
+ </p>
556
+ <div className="flex gap-3 justify-end">
557
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingBulkDelete(false)}>
558
+ Cancel
559
+ </Button>
560
+ <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={handleBulkDelete}>
561
+ Delete
562
+ </Button>
563
+ </div>
564
+ </Modal>
461
565
  </div>
462
566
  );
463
567
  }