@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
@@ -1,348 +1,377 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- Contact,
9
- ContactAddressKind,
10
- ContactEmail,
11
- ContactPhone,
12
- ContactPostalAddress,
13
- createContact,
14
- updateContact,
15
- } from "@rapidmx/react-shared/contacts/contactsApi.js";
16
- import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
17
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
18
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
19
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
20
- import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
21
-
22
- const INPUT_CLASS =
23
- "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
24
- const SELECT_CLASS =
25
- "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
26
-
27
- export interface ContactFormProps {
28
- contact?: Contact;
29
- /** Create mode: the default mailbox (and its Contacts folder) to create in. */
30
- mailboxUid?: string;
31
- folderUid?: string;
32
- /** Create mode: every mailbox the contact could be created in - with more than one, a Mailbox selector
33
- * is shown. Choosing a mailbox other than `mailboxUid` looks up that mailbox's own Contacts folder. */
34
- mailboxes?: Mailbox[];
35
- onSaved: (contact: Contact) => void;
36
- onCancel: () => void;
37
- }
38
-
39
- const ADDRESS_KINDS: ContactAddressKind[] = ["home", "work", "other"];
40
-
41
- /**
42
- * A contact's create/edit form. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`) and the
43
- * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
44
- * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
45
- */
46
- export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }: ContactFormProps) {
47
- const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
48
- const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
49
- const [givenName, setGivenName] = useState(contact?.givenName ?? "");
50
- const [surname, setSurname] = useState(contact?.surname ?? "");
51
- const [company, setCompany] = useState(contact?.company ?? "");
52
- const [jobTitle, setJobTitle] = useState(contact?.jobTitle ?? "");
53
- const [notes, setNotes] = useState(contact?.notes ?? "");
54
- const [favorite, setFavorite] = useState(contact?.favorite ?? false);
55
- const [categoriesText, setCategoriesText] = useState((contact?.categories ?? []).join(", "));
56
- const [emails, setEmails] = useState<ContactEmail[]>(contact?.emails ?? []);
57
- const [phones, setPhones] = useState<ContactPhone[]>(contact?.phones ?? []);
58
- const [address, setAddress] = useState<ContactPostalAddress | null>(contact?.addresses[0] ?? null);
59
- const [error, setError] = useState<string | null>(null);
60
- const [saving, setSaving] = useState(false);
61
-
62
- function updateEmail(index: number, patch: Partial<ContactEmail>) {
63
- setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
64
- }
65
- function removeEmail(index: number) {
66
- setEmails((prev) => prev.filter((_, i) => i !== index));
67
- }
68
- function updatePhone(index: number, patch: Partial<ContactPhone>) {
69
- setPhones((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
70
- }
71
- function removePhone(index: number) {
72
- setPhones((prev) => prev.filter((_, i) => i !== index));
73
- }
74
-
75
- async function handleSubmit(e: FormEvent) {
76
- e.preventDefault();
77
- setError(null);
78
-
79
- if (!displayName.trim()) {
80
- setError("A display name is required.");
81
- return;
82
- }
83
-
84
- setSaving(true);
85
- try {
86
- const categories = categoriesText
87
- .split(",")
88
- .map((c) => c.trim())
89
- .filter(Boolean);
90
- const input = {
91
- displayName: displayName.trim(),
92
- givenName: givenName.trim() || undefined,
93
- surname: surname.trim() || undefined,
94
- company: company.trim() || undefined,
95
- jobTitle: jobTitle.trim() || undefined,
96
- notes: notes.trim() || undefined,
97
- favorite,
98
- categories,
99
- emails,
100
- phones,
101
- addresses: address ? [address] : [],
102
- };
103
- let saved: Contact;
104
- if (contact) {
105
- saved = await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input });
106
- } else {
107
- const targetFolderUid =
108
- targetMailboxUid === mailboxUid ? folderUid : await findWellKnownFolderUid(targetMailboxUid as string, "contacts");
109
- if (!targetFolderUid) {
110
- setError("That mailbox has no Contacts folder.");
111
- return;
112
- }
113
- saved = await createContact({ mailboxUid: targetMailboxUid as string, folderUid: targetFolderUid, ...input });
114
- }
115
- onSaved(saved);
116
- } catch (err) {
117
- setError(err instanceof ApiRequestError ? err.message : "Could not save this contact.");
118
- } finally {
119
- setSaving(false);
120
- }
121
- }
122
-
123
- return (
124
- <form onSubmit={handleSubmit} className="max-w-xl flex flex-col gap-1">
125
- <h1 className="text-xl font-bold uppercase tracking-wide mb-3">{contact ? "Edit contact" : "New contact"}</h1>
126
-
127
- {error && <Alert>{error}</Alert>}
128
-
129
- {!contact && mailboxes && mailboxes.length > 1 && (
130
- <FormField label="Mailbox" htmlFor="contact-mailbox">
131
- <select
132
- id="contact-mailbox"
133
- className={`${SELECT_CLASS} w-full`}
134
- value={targetMailboxUid}
135
- onChange={(e) => setTargetMailboxUid(e.target.value)}
136
- >
137
- {mailboxes.map((mb) => (
138
- <option key={mb.uid} value={mb.uid}>
139
- {mb.displayName}
140
- {mb.ownerUserUid ? "" : " (shared)"}
141
- </option>
142
- ))}
143
- </select>
144
- </FormField>
145
- )}
146
-
147
- <FormField label="Display name" htmlFor="contact-displayName">
148
- <input
149
- id="contact-displayName"
150
- type="text"
151
- className={INPUT_CLASS}
152
- value={displayName}
153
- onChange={(e) => setDisplayName(e.target.value)}
154
- />
155
- </FormField>
156
-
157
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
158
- <FormField label="First name" htmlFor="contact-givenName">
159
- <input id="contact-givenName" type="text" className={INPUT_CLASS} value={givenName} onChange={(e) => setGivenName(e.target.value)} />
160
- </FormField>
161
- <FormField label="Last name" htmlFor="contact-surname">
162
- <input id="contact-surname" type="text" className={INPUT_CLASS} value={surname} onChange={(e) => setSurname(e.target.value)} />
163
- </FormField>
164
- </div>
165
-
166
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
167
- <FormField label="Company" htmlFor="contact-company">
168
- <input id="contact-company" type="text" className={INPUT_CLASS} value={company} onChange={(e) => setCompany(e.target.value)} />
169
- </FormField>
170
- <FormField label="Job title" htmlFor="contact-jobTitle">
171
- <input id="contact-jobTitle" type="text" className={INPUT_CLASS} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
172
- </FormField>
173
- </div>
174
-
175
- <label className="flex items-center gap-2 text-sm mb-4">
176
- <input type="checkbox" checked={favorite} onChange={(e) => setFavorite(e.target.checked)} />
177
- Favorite
178
- </label>
179
-
180
- <FormField label="Categories (comma-separated)" htmlFor="contact-categories">
181
- <input
182
- id="contact-categories"
183
- type="text"
184
- className={INPUT_CLASS}
185
- value={categoriesText}
186
- onChange={(e) => setCategoriesText(e.target.value)}
187
- placeholder="VIP, Work"
188
- />
189
- </FormField>
190
-
191
- <FormField label="Email" htmlFor="contact-emails">
192
- <div id="contact-emails" className="flex flex-col gap-2">
193
- {emails.map((email, i) => (
194
- <div key={i} className="flex gap-2">
195
- <input
196
- type="email"
197
- className={`${INPUT_CLASS} flex-1`}
198
- value={email.address}
199
- onChange={(e) => updateEmail(i, { address: e.target.value })}
200
- aria-label={`Email address ${i + 1}`}
201
- />
202
- <select
203
- className={SELECT_CLASS}
204
- value={email.type}
205
- onChange={(e) => updateEmail(i, { type: e.target.value as ContactAddressKind })}
206
- aria-label={`Email type ${i + 1}`}
207
- >
208
- {ADDRESS_KINDS.map((kind) => (
209
- <option key={kind} value={kind}>
210
- {kind}
211
- </option>
212
- ))}
213
- </select>
214
- <button type="button" onClick={() => removeEmail(i)} className="text-sm text-danger px-2" aria-label={`Remove email ${i + 1}`}>
215
- &times;
216
- </button>
217
- </div>
218
- ))}
219
- <button
220
- type="button"
221
- onClick={() => setEmails((prev) => [...prev, { address: "", type: "work" }])}
222
- className="self-start text-xs font-medium text-primary-dark hover:underline"
223
- >
224
- + Add email
225
- </button>
226
- </div>
227
- </FormField>
228
-
229
- <FormField label="Phone" htmlFor="contact-phones">
230
- <div id="contact-phones" className="flex flex-col gap-2">
231
- {phones.map((phone, i) => (
232
- <div key={i} className="flex gap-2">
233
- <input
234
- type="tel"
235
- className={`${INPUT_CLASS} flex-1`}
236
- value={phone.phoneNumber}
237
- onChange={(e) => updatePhone(i, { phoneNumber: e.target.value })}
238
- aria-label={`Phone number ${i + 1}`}
239
- />
240
- <select
241
- className={SELECT_CLASS}
242
- value={phone.type}
243
- onChange={(e) => updatePhone(i, { type: e.target.value as ContactAddressKind })}
244
- aria-label={`Phone type ${i + 1}`}
245
- >
246
- {ADDRESS_KINDS.map((kind) => (
247
- <option key={kind} value={kind}>
248
- {kind}
249
- </option>
250
- ))}
251
- </select>
252
- <button type="button" onClick={() => removePhone(i)} className="text-sm text-danger px-2" aria-label={`Remove phone ${i + 1}`}>
253
- &times;
254
- </button>
255
- </div>
256
- ))}
257
- <button
258
- type="button"
259
- onClick={() => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }])}
260
- className="self-start text-xs font-medium text-primary-dark hover:underline"
261
- >
262
- + Add phone
263
- </button>
264
- </div>
265
- </FormField>
266
-
267
- <FormField label="Address" htmlFor="contact-address">
268
- {address ? (
269
- <div className="flex flex-col gap-2">
270
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
271
- <input
272
- type="text"
273
- placeholder="Street"
274
- className={INPUT_CLASS}
275
- value={address.street ?? ""}
276
- onChange={(e) => setAddress({ ...address, street: e.target.value })}
277
- />
278
- <input
279
- type="text"
280
- placeholder="City"
281
- className={INPUT_CLASS}
282
- value={address.city ?? ""}
283
- onChange={(e) => setAddress({ ...address, city: e.target.value })}
284
- />
285
- <input
286
- type="text"
287
- placeholder="State/Province"
288
- className={INPUT_CLASS}
289
- value={address.state ?? ""}
290
- onChange={(e) => setAddress({ ...address, state: e.target.value })}
291
- />
292
- <input
293
- type="text"
294
- placeholder="Postal code"
295
- className={INPUT_CLASS}
296
- value={address.postalCode ?? ""}
297
- onChange={(e) => setAddress({ ...address, postalCode: e.target.value })}
298
- />
299
- <input
300
- type="text"
301
- placeholder="Country"
302
- className={INPUT_CLASS}
303
- value={address.country ?? ""}
304
- onChange={(e) => setAddress({ ...address, country: e.target.value })}
305
- />
306
- <select
307
- className={SELECT_CLASS}
308
- value={address.type}
309
- onChange={(e) => setAddress({ ...address, type: e.target.value as ContactAddressKind })}
310
- aria-label="Address type"
311
- >
312
- {ADDRESS_KINDS.map((kind) => (
313
- <option key={kind} value={kind}>
314
- {kind}
315
- </option>
316
- ))}
317
- </select>
318
- </div>
319
- <button type="button" onClick={() => setAddress(null)} className="self-start text-xs font-medium text-danger hover:underline">
320
- Remove address
321
- </button>
322
- </div>
323
- ) : (
324
- <button
325
- type="button"
326
- onClick={() => setAddress({ type: "home" })}
327
- className="self-start text-xs font-medium text-primary-dark hover:underline"
328
- >
329
- + Add address
330
- </button>
331
- )}
332
- </FormField>
333
-
334
- <FormField label="Notes" htmlFor="contact-notes">
335
- <textarea id="contact-notes" rows={3} className={INPUT_CLASS} value={notes} onChange={(e) => setNotes(e.target.value)} />
336
- </FormField>
337
-
338
- <div className="flex gap-3 mt-2">
339
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
340
- Save
341
- </Button>
342
- <Button type="button" variant="secondary" className="!w-auto" onClick={onCancel}>
343
- Cancel
344
- </Button>
345
- </div>
346
- </form>
347
- );
348
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ Contact,
9
+ ContactAddressKind,
10
+ ContactEmail,
11
+ ContactPatch,
12
+ ContactPhone,
13
+ ContactPostalAddress,
14
+ createContact,
15
+ updateContact,
16
+ } from "@rapidmx/react-shared/contacts/contactsApi.js";
17
+ import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
18
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
19
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
20
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
21
+ import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
22
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
23
+
24
+ const INPUT_CLASS =
25
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
26
+ const SELECT_CLASS =
27
+ "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
28
+
29
+ export interface ContactFormProps {
30
+ contact?: Contact;
31
+ /** Create mode: the default mailbox (and its Contacts folder) to create in. */
32
+ mailboxUid?: string;
33
+ folderUid?: string;
34
+ /** Create mode: every mailbox the contact could be created in - with more than one, a Mailbox selector
35
+ * is shown. Choosing a mailbox other than `mailboxUid` looks up that mailbox's own Contacts folder. */
36
+ mailboxes?: Mailbox[];
37
+ onSaved: (contact: Contact) => void;
38
+ onCancel: () => void;
39
+ }
40
+
41
+ const ADDRESS_KINDS: ContactAddressKind[] = ["home", "work", "other"];
42
+
43
+ /**
44
+ * A contact's create/edit form. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`) and the
45
+ * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
46
+ * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
47
+ */
48
+ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }: ContactFormProps) {
49
+ const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
50
+ const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
51
+ const [givenName, setGivenName] = useState(contact?.givenName ?? "");
52
+ const [surname, setSurname] = useState(contact?.surname ?? "");
53
+ const [company, setCompany] = useState(contact?.company ?? "");
54
+ const [jobTitle, setJobTitle] = useState(contact?.jobTitle ?? "");
55
+ const [notes, setNotes] = useState(contact?.notes ?? "");
56
+ const [favorite, setFavorite] = useState(contact?.favorite ?? false);
57
+ const [categoriesText, setCategoriesText] = useState((contact?.categories ?? []).join(", "));
58
+ const [emails, setEmails] = useState<ContactEmail[]>(contact?.emails ?? []);
59
+ const [phones, setPhones] = useState<ContactPhone[]>(contact?.phones ?? []);
60
+ // Every stored address is kept and editable - editing a contact must never silently drop addresses
61
+ // beyond the first.
62
+ const [addresses, setAddresses] = useState<ContactPostalAddress[]>(contact?.addresses ?? []);
63
+ const [error, setError] = useState<string | null>(null);
64
+ const [saving, setSaving] = useState(false);
65
+
66
+ function updateEmail(index: number, patch: Partial<ContactEmail>) {
67
+ setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
68
+ }
69
+ function removeEmail(index: number) {
70
+ setEmails((prev) => prev.filter((_, i) => i !== index));
71
+ }
72
+ function updatePhone(index: number, patch: Partial<ContactPhone>) {
73
+ setPhones((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
74
+ }
75
+ function removePhone(index: number) {
76
+ setPhones((prev) => prev.filter((_, i) => i !== index));
77
+ }
78
+ function updateAddress(index: number, patch: Partial<ContactPostalAddress>) {
79
+ setAddresses((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
80
+ }
81
+ function removeAddress(index: number) {
82
+ setAddresses((prev) => prev.filter((_, i) => i !== index));
83
+ }
84
+
85
+ async function handleSubmit(e: FormEvent) {
86
+ e.preventDefault();
87
+ setError(null);
88
+
89
+ if (!displayName.trim()) {
90
+ setError("A display name is required.");
91
+ return;
92
+ }
93
+
94
+ setSaving(true);
95
+ try {
96
+ const categories = categoriesText
97
+ .split(",")
98
+ .map((c) => c.trim())
99
+ .filter(Boolean);
100
+ // An update merges only the fields in its body, so a field the user cleared is sent as an
101
+ // explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
102
+ const cleared = contact ? null : undefined;
103
+ const input = {
104
+ displayName: displayName.trim(),
105
+ givenName: givenName.trim() || cleared,
106
+ surname: surname.trim() || cleared,
107
+ company: company.trim() || cleared,
108
+ jobTitle: jobTitle.trim() || cleared,
109
+ notes: notes.trim() || cleared,
110
+ favorite,
111
+ categories,
112
+ emails,
113
+ phones,
114
+ addresses,
115
+ };
116
+ let saved: Contact;
117
+ if (contact) {
118
+ saved = await updateContact({
119
+ uid: contact.uid,
120
+ version: contact.version,
121
+ ...(input as Omit<ContactPatch, "uid" | "version">),
122
+ });
123
+ } else {
124
+ const targetFolderUid =
125
+ targetMailboxUid === mailboxUid ? folderUid : await findWellKnownFolderUid(targetMailboxUid as string, "contacts");
126
+ if (!targetFolderUid) {
127
+ setError("That mailbox has no Contacts folder.");
128
+ return;
129
+ }
130
+ saved = await createContact({
131
+ mailboxUid: targetMailboxUid as string,
132
+ folderUid: targetFolderUid,
133
+ ...(input as Omit<Parameters<typeof createContact>[0], "mailboxUid" | "folderUid">),
134
+ });
135
+ }
136
+ // A message opened later in this page load must see the changed contact's signing keys.
137
+ clearPinnedSignerCache();
138
+ onSaved(saved);
139
+ } catch (err) {
140
+ setError(err instanceof ApiRequestError ? err.message : "Could not save this contact.");
141
+ } finally {
142
+ setSaving(false);
143
+ }
144
+ }
145
+
146
+ return (
147
+ <form onSubmit={handleSubmit} className="max-w-xl flex flex-col gap-1">
148
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-3">{contact ? "Edit contact" : "New contact"}</h1>
149
+
150
+ {error && <Alert>{error}</Alert>}
151
+
152
+ {!contact && mailboxes && mailboxes.length > 1 && (
153
+ <FormField label="Mailbox" htmlFor="contact-mailbox">
154
+ <select
155
+ id="contact-mailbox"
156
+ className={`${SELECT_CLASS} w-full`}
157
+ value={targetMailboxUid}
158
+ onChange={(e) => setTargetMailboxUid(e.target.value)}
159
+ >
160
+ {mailboxes.map((mb) => (
161
+ <option key={mb.uid} value={mb.uid}>
162
+ {mb.displayName}
163
+ {mb.ownerUserUid ? "" : " (shared)"}
164
+ </option>
165
+ ))}
166
+ </select>
167
+ </FormField>
168
+ )}
169
+
170
+ <FormField label="Display name" htmlFor="contact-displayName">
171
+ <input
172
+ id="contact-displayName"
173
+ type="text"
174
+ className={INPUT_CLASS}
175
+ value={displayName}
176
+ onChange={(e) => setDisplayName(e.target.value)}
177
+ />
178
+ </FormField>
179
+
180
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
181
+ <FormField label="First name" htmlFor="contact-givenName">
182
+ <input id="contact-givenName" type="text" className={INPUT_CLASS} value={givenName} onChange={(e) => setGivenName(e.target.value)} />
183
+ </FormField>
184
+ <FormField label="Last name" htmlFor="contact-surname">
185
+ <input id="contact-surname" type="text" className={INPUT_CLASS} value={surname} onChange={(e) => setSurname(e.target.value)} />
186
+ </FormField>
187
+ </div>
188
+
189
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
190
+ <FormField label="Company" htmlFor="contact-company">
191
+ <input id="contact-company" type="text" className={INPUT_CLASS} value={company} onChange={(e) => setCompany(e.target.value)} />
192
+ </FormField>
193
+ <FormField label="Job title" htmlFor="contact-jobTitle">
194
+ <input id="contact-jobTitle" type="text" className={INPUT_CLASS} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
195
+ </FormField>
196
+ </div>
197
+
198
+ <label className="flex items-center gap-2 text-sm mb-4">
199
+ <input type="checkbox" checked={favorite} onChange={(e) => setFavorite(e.target.checked)} />
200
+ Favorite
201
+ </label>
202
+
203
+ <FormField label="Categories (comma-separated)" htmlFor="contact-categories">
204
+ <input
205
+ id="contact-categories"
206
+ type="text"
207
+ className={INPUT_CLASS}
208
+ value={categoriesText}
209
+ onChange={(e) => setCategoriesText(e.target.value)}
210
+ placeholder="VIP, Work"
211
+ />
212
+ </FormField>
213
+
214
+ <FormField label="Email" htmlFor="contact-emails">
215
+ <div id="contact-emails" className="flex flex-col gap-2">
216
+ {emails.map((email, i) => (
217
+ <div key={i} className="flex gap-2">
218
+ <input
219
+ type="email"
220
+ className={`${INPUT_CLASS} flex-1`}
221
+ value={email.address}
222
+ onChange={(e) => updateEmail(i, { address: e.target.value })}
223
+ aria-label={`Email address ${i + 1}`}
224
+ />
225
+ <select
226
+ className={SELECT_CLASS}
227
+ value={email.type}
228
+ onChange={(e) => updateEmail(i, { type: e.target.value as ContactAddressKind })}
229
+ aria-label={`Email type ${i + 1}`}
230
+ >
231
+ {ADDRESS_KINDS.map((kind) => (
232
+ <option key={kind} value={kind}>
233
+ {kind}
234
+ </option>
235
+ ))}
236
+ </select>
237
+ <button type="button" onClick={() => removeEmail(i)} className="text-sm text-danger px-2" aria-label={`Remove email ${i + 1}`}>
238
+ &times;
239
+ </button>
240
+ </div>
241
+ ))}
242
+ <button
243
+ type="button"
244
+ onClick={() => setEmails((prev) => [...prev, { address: "", type: "work" }])}
245
+ className="self-start text-xs font-medium text-primary-dark hover:underline"
246
+ >
247
+ + Add email
248
+ </button>
249
+ </div>
250
+ </FormField>
251
+
252
+ <FormField label="Phone" htmlFor="contact-phones">
253
+ <div id="contact-phones" className="flex flex-col gap-2">
254
+ {phones.map((phone, i) => (
255
+ <div key={i} className="flex gap-2">
256
+ <input
257
+ type="tel"
258
+ className={`${INPUT_CLASS} flex-1`}
259
+ value={phone.phoneNumber}
260
+ onChange={(e) => updatePhone(i, { phoneNumber: e.target.value })}
261
+ aria-label={`Phone number ${i + 1}`}
262
+ />
263
+ <select
264
+ className={SELECT_CLASS}
265
+ value={phone.type}
266
+ onChange={(e) => updatePhone(i, { type: e.target.value as ContactAddressKind })}
267
+ aria-label={`Phone type ${i + 1}`}
268
+ >
269
+ {ADDRESS_KINDS.map((kind) => (
270
+ <option key={kind} value={kind}>
271
+ {kind}
272
+ </option>
273
+ ))}
274
+ </select>
275
+ <button type="button" onClick={() => removePhone(i)} className="text-sm text-danger px-2" aria-label={`Remove phone ${i + 1}`}>
276
+ &times;
277
+ </button>
278
+ </div>
279
+ ))}
280
+ <button
281
+ type="button"
282
+ onClick={() => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }])}
283
+ className="self-start text-xs font-medium text-primary-dark hover:underline"
284
+ >
285
+ + Add phone
286
+ </button>
287
+ </div>
288
+ </FormField>
289
+
290
+ <FormField label="Address" htmlFor="contact-address">
291
+ <div className="flex flex-col gap-3">
292
+ {addresses.map((address, i) => (
293
+ <div key={i} className="flex flex-col gap-2">
294
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
295
+ <input
296
+ type="text"
297
+ placeholder="Street"
298
+ className={INPUT_CLASS}
299
+ value={address.street ?? ""}
300
+ onChange={(e) => updateAddress(i, { street: e.target.value })}
301
+ />
302
+ <input
303
+ type="text"
304
+ placeholder="City"
305
+ className={INPUT_CLASS}
306
+ value={address.city ?? ""}
307
+ onChange={(e) => updateAddress(i, { city: e.target.value })}
308
+ />
309
+ <input
310
+ type="text"
311
+ placeholder="State/Province"
312
+ className={INPUT_CLASS}
313
+ value={address.state ?? ""}
314
+ onChange={(e) => updateAddress(i, { state: e.target.value })}
315
+ />
316
+ <input
317
+ type="text"
318
+ placeholder="Postal code"
319
+ className={INPUT_CLASS}
320
+ value={address.postalCode ?? ""}
321
+ onChange={(e) => updateAddress(i, { postalCode: e.target.value })}
322
+ />
323
+ <input
324
+ type="text"
325
+ placeholder="Country"
326
+ className={INPUT_CLASS}
327
+ value={address.country ?? ""}
328
+ onChange={(e) => updateAddress(i, { country: e.target.value })}
329
+ />
330
+ <select
331
+ className={SELECT_CLASS}
332
+ value={address.type}
333
+ onChange={(e) => updateAddress(i, { type: e.target.value as ContactAddressKind })}
334
+ aria-label={i === 0 ? "Address type" : `Address type ${i + 1}`}
335
+ >
336
+ {ADDRESS_KINDS.map((kind) => (
337
+ <option key={kind} value={kind}>
338
+ {kind}
339
+ </option>
340
+ ))}
341
+ </select>
342
+ </div>
343
+ <button
344
+ type="button"
345
+ onClick={() => removeAddress(i)}
346
+ className="self-start text-xs font-medium text-danger hover:underline"
347
+ aria-label={i === 0 ? "Remove address" : `Remove address ${i + 1}`}
348
+ >
349
+ Remove address
350
+ </button>
351
+ </div>
352
+ ))}
353
+ <button
354
+ type="button"
355
+ onClick={() => setAddresses((prev) => [...prev, { type: "home" }])}
356
+ className="self-start text-xs font-medium text-primary-dark hover:underline"
357
+ >
358
+ + Add address
359
+ </button>
360
+ </div>
361
+ </FormField>
362
+
363
+ <FormField label="Notes" htmlFor="contact-notes">
364
+ <textarea id="contact-notes" rows={3} className={INPUT_CLASS} value={notes} onChange={(e) => setNotes(e.target.value)} />
365
+ </FormField>
366
+
367
+ <div className="flex gap-3 mt-2">
368
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
369
+ Save
370
+ </Button>
371
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onCancel}>
372
+ Cancel
373
+ </Button>
374
+ </div>
375
+ </form>
376
+ );
377
+ }