@rapidmx/web-client 0.3.1 → 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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -1,314 +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 Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
18
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
19
-
20
- const INPUT_CLASS =
21
- "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
22
- const SELECT_CLASS =
23
- "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
24
-
25
- export interface ContactFormProps {
26
- contact?: Contact;
27
- mailboxUid?: string;
28
- folderUid?: string;
29
- onSaved: (contact: Contact) => void;
30
- onCancel: () => void;
31
- }
32
-
33
- const ADDRESS_KINDS: ContactAddressKind[] = ["home", "work", "other"];
34
-
35
- /**
36
- * A contact's create/edit form. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`) and the
37
- * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
38
- * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
39
- */
40
- export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, onCancel }: ContactFormProps) {
41
- const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
42
- const [givenName, setGivenName] = useState(contact?.givenName ?? "");
43
- const [surname, setSurname] = useState(contact?.surname ?? "");
44
- const [company, setCompany] = useState(contact?.company ?? "");
45
- const [jobTitle, setJobTitle] = useState(contact?.jobTitle ?? "");
46
- const [notes, setNotes] = useState(contact?.notes ?? "");
47
- const [favorite, setFavorite] = useState(contact?.favorite ?? false);
48
- const [categoriesText, setCategoriesText] = useState((contact?.categories ?? []).join(", "));
49
- const [emails, setEmails] = useState<ContactEmail[]>(contact?.emails ?? []);
50
- const [phones, setPhones] = useState<ContactPhone[]>(contact?.phones ?? []);
51
- const [address, setAddress] = useState<ContactPostalAddress | null>(contact?.addresses[0] ?? null);
52
- const [error, setError] = useState<string | null>(null);
53
- const [saving, setSaving] = useState(false);
54
-
55
- function updateEmail(index: number, patch: Partial<ContactEmail>) {
56
- setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
57
- }
58
- function removeEmail(index: number) {
59
- setEmails((prev) => prev.filter((_, i) => i !== index));
60
- }
61
- function updatePhone(index: number, patch: Partial<ContactPhone>) {
62
- setPhones((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
63
- }
64
- function removePhone(index: number) {
65
- setPhones((prev) => prev.filter((_, i) => i !== index));
66
- }
67
-
68
- async function handleSubmit(e: FormEvent) {
69
- e.preventDefault();
70
- setError(null);
71
-
72
- if (!displayName.trim()) {
73
- setError("A display name is required.");
74
- return;
75
- }
76
-
77
- setSaving(true);
78
- try {
79
- const categories = categoriesText
80
- .split(",")
81
- .map((c) => c.trim())
82
- .filter(Boolean);
83
- const input = {
84
- displayName: displayName.trim(),
85
- givenName: givenName.trim() || undefined,
86
- surname: surname.trim() || undefined,
87
- company: company.trim() || undefined,
88
- jobTitle: jobTitle.trim() || undefined,
89
- notes: notes.trim() || undefined,
90
- favorite,
91
- categories,
92
- emails,
93
- phones,
94
- addresses: address ? [address] : [],
95
- };
96
- const saved = contact
97
- ? await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input })
98
- : await createContact({ mailboxUid: mailboxUid as string, folderUid: folderUid as string, ...input });
99
- onSaved(saved);
100
- } catch (err) {
101
- setError(err instanceof ApiRequestError ? err.message : "Could not save this contact.");
102
- } finally {
103
- setSaving(false);
104
- }
105
- }
106
-
107
- return (
108
- <form onSubmit={handleSubmit} className="max-w-xl flex flex-col gap-1">
109
- <h1 className="text-xl font-bold uppercase tracking-wide mb-3">{contact ? "Edit contact" : "New contact"}</h1>
110
-
111
- {error && <Alert>{error}</Alert>}
112
-
113
- <FormField label="Display name" htmlFor="contact-displayName">
114
- <input
115
- id="contact-displayName"
116
- type="text"
117
- className={INPUT_CLASS}
118
- value={displayName}
119
- onChange={(e) => setDisplayName(e.target.value)}
120
- />
121
- </FormField>
122
-
123
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
124
- <FormField label="First name" htmlFor="contact-givenName">
125
- <input id="contact-givenName" type="text" className={INPUT_CLASS} value={givenName} onChange={(e) => setGivenName(e.target.value)} />
126
- </FormField>
127
- <FormField label="Last name" htmlFor="contact-surname">
128
- <input id="contact-surname" type="text" className={INPUT_CLASS} value={surname} onChange={(e) => setSurname(e.target.value)} />
129
- </FormField>
130
- </div>
131
-
132
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
133
- <FormField label="Company" htmlFor="contact-company">
134
- <input id="contact-company" type="text" className={INPUT_CLASS} value={company} onChange={(e) => setCompany(e.target.value)} />
135
- </FormField>
136
- <FormField label="Job title" htmlFor="contact-jobTitle">
137
- <input id="contact-jobTitle" type="text" className={INPUT_CLASS} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
138
- </FormField>
139
- </div>
140
-
141
- <label className="flex items-center gap-2 text-sm mb-4">
142
- <input type="checkbox" checked={favorite} onChange={(e) => setFavorite(e.target.checked)} />
143
- Favorite
144
- </label>
145
-
146
- <FormField label="Categories (comma-separated)" htmlFor="contact-categories">
147
- <input
148
- id="contact-categories"
149
- type="text"
150
- className={INPUT_CLASS}
151
- value={categoriesText}
152
- onChange={(e) => setCategoriesText(e.target.value)}
153
- placeholder="VIP, Work"
154
- />
155
- </FormField>
156
-
157
- <FormField label="Email" htmlFor="contact-emails">
158
- <div id="contact-emails" className="flex flex-col gap-2">
159
- {emails.map((email, i) => (
160
- <div key={i} className="flex gap-2">
161
- <input
162
- type="email"
163
- className={`${INPUT_CLASS} flex-1`}
164
- value={email.address}
165
- onChange={(e) => updateEmail(i, { address: e.target.value })}
166
- aria-label={`Email address ${i + 1}`}
167
- />
168
- <select
169
- className={SELECT_CLASS}
170
- value={email.type}
171
- onChange={(e) => updateEmail(i, { type: e.target.value as ContactAddressKind })}
172
- aria-label={`Email type ${i + 1}`}
173
- >
174
- {ADDRESS_KINDS.map((kind) => (
175
- <option key={kind} value={kind}>
176
- {kind}
177
- </option>
178
- ))}
179
- </select>
180
- <button type="button" onClick={() => removeEmail(i)} className="text-sm text-danger px-2" aria-label={`Remove email ${i + 1}`}>
181
- &times;
182
- </button>
183
- </div>
184
- ))}
185
- <button
186
- type="button"
187
- onClick={() => setEmails((prev) => [...prev, { address: "", type: "work" }])}
188
- className="self-start text-xs font-medium text-primary-dark hover:underline"
189
- >
190
- + Add email
191
- </button>
192
- </div>
193
- </FormField>
194
-
195
- <FormField label="Phone" htmlFor="contact-phones">
196
- <div id="contact-phones" className="flex flex-col gap-2">
197
- {phones.map((phone, i) => (
198
- <div key={i} className="flex gap-2">
199
- <input
200
- type="tel"
201
- className={`${INPUT_CLASS} flex-1`}
202
- value={phone.phoneNumber}
203
- onChange={(e) => updatePhone(i, { phoneNumber: e.target.value })}
204
- aria-label={`Phone number ${i + 1}`}
205
- />
206
- <select
207
- className={SELECT_CLASS}
208
- value={phone.type}
209
- onChange={(e) => updatePhone(i, { type: e.target.value as ContactAddressKind })}
210
- aria-label={`Phone type ${i + 1}`}
211
- >
212
- {ADDRESS_KINDS.map((kind) => (
213
- <option key={kind} value={kind}>
214
- {kind}
215
- </option>
216
- ))}
217
- </select>
218
- <button type="button" onClick={() => removePhone(i)} className="text-sm text-danger px-2" aria-label={`Remove phone ${i + 1}`}>
219
- &times;
220
- </button>
221
- </div>
222
- ))}
223
- <button
224
- type="button"
225
- onClick={() => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }])}
226
- className="self-start text-xs font-medium text-primary-dark hover:underline"
227
- >
228
- + Add phone
229
- </button>
230
- </div>
231
- </FormField>
232
-
233
- <FormField label="Address" htmlFor="contact-address">
234
- {address ? (
235
- <div className="flex flex-col gap-2">
236
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
237
- <input
238
- type="text"
239
- placeholder="Street"
240
- className={INPUT_CLASS}
241
- value={address.street ?? ""}
242
- onChange={(e) => setAddress({ ...address, street: e.target.value })}
243
- />
244
- <input
245
- type="text"
246
- placeholder="City"
247
- className={INPUT_CLASS}
248
- value={address.city ?? ""}
249
- onChange={(e) => setAddress({ ...address, city: e.target.value })}
250
- />
251
- <input
252
- type="text"
253
- placeholder="State/Province"
254
- className={INPUT_CLASS}
255
- value={address.state ?? ""}
256
- onChange={(e) => setAddress({ ...address, state: e.target.value })}
257
- />
258
- <input
259
- type="text"
260
- placeholder="Postal code"
261
- className={INPUT_CLASS}
262
- value={address.postalCode ?? ""}
263
- onChange={(e) => setAddress({ ...address, postalCode: e.target.value })}
264
- />
265
- <input
266
- type="text"
267
- placeholder="Country"
268
- className={INPUT_CLASS}
269
- value={address.country ?? ""}
270
- onChange={(e) => setAddress({ ...address, country: e.target.value })}
271
- />
272
- <select
273
- className={SELECT_CLASS}
274
- value={address.type}
275
- onChange={(e) => setAddress({ ...address, type: e.target.value as ContactAddressKind })}
276
- aria-label="Address type"
277
- >
278
- {ADDRESS_KINDS.map((kind) => (
279
- <option key={kind} value={kind}>
280
- {kind}
281
- </option>
282
- ))}
283
- </select>
284
- </div>
285
- <button type="button" onClick={() => setAddress(null)} className="self-start text-xs font-medium text-danger hover:underline">
286
- Remove address
287
- </button>
288
- </div>
289
- ) : (
290
- <button
291
- type="button"
292
- onClick={() => setAddress({ type: "home" })}
293
- className="self-start text-xs font-medium text-primary-dark hover:underline"
294
- >
295
- + Add address
296
- </button>
297
- )}
298
- </FormField>
299
-
300
- <FormField label="Notes" htmlFor="contact-notes">
301
- <textarea id="contact-notes" rows={3} className={INPUT_CLASS} value={notes} onChange={(e) => setNotes(e.target.value)} />
302
- </FormField>
303
-
304
- <div className="flex gap-3 mt-2">
305
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
306
- Save
307
- </Button>
308
- <Button type="button" variant="secondary" className="!w-auto" onClick={onCancel}>
309
- Cancel
310
- </Button>
311
- </div>
312
- </form>
313
- );
314
- }
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
+ }