@rapidmx/web-client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -4,34 +4,41 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
- import { CALENDAR_COLOR_PALETTE, colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
7
+ import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
8
8
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
9
  /**
10
- * Calendar's left-sidebar checklist of "My calendars" — one row per `calendar`-type folder, a color
11
- * swatch matching `colorForFolder`, and a checkbox controlling whether that calendar's events are
12
- * currently shown (the checked set is owned by `apps/www/calendar/index.tsx`, since it drives which
13
- * folders get fanned out to on every reload). "+ Add calendar" is a real `createFolder({type:
14
- * "calendar", ...})` call — see the Phase 4 plan's own note that folder creation has no type
15
- * restriction server-side, so this needed no new backend route.
10
+ * Calendar's left-sidebar checklist of calendars — one row per `calendar`-type folder, a color swatch,
11
+ * and a checkbox controlling whether that calendar's events are currently shown (the checked set is owned
12
+ * by `apps/www/calendar/index.tsx`, since it drives which folders get fanned out to on every reload).
13
+ * With one mailbox this is a single "My calendars" list, as before; with several, each mailbox gets its
14
+ * own section (a shared mailbox labeled as such, with a color-dot accent) and its own "+ Add calendar",
15
+ * which creates the calendar in that mailbox. "+ Add calendar" is a real `createFolder({type: "calendar",
16
+ * ...})` call — folder creation has no type restriction server-side, so this needed no new backend route.
16
17
  */
17
- export default function CalendarListSidebar({ calendars, checkedFolderUids, onToggle, onAddCalendar }) {
18
- const [adding, setAdding] = useState(false);
18
+ export default function CalendarListSidebar({ mailboxCalendars, checkedFolderUids, onToggle, onAddCalendar, colorFor = (folder) => colorForFolder(folder), }) {
19
+ const [addingFor, setAddingFor] = useState(null);
19
20
  const [name, setName] = useState("");
20
21
  const [color, setColor] = useState(CALENDAR_COLOR_PALETTE[0]);
21
22
  const [error, setError] = useState(null);
22
23
  const [saving, setSaving] = useState(false);
24
+ const multiple = mailboxCalendars.length > 1;
25
+ function startAdding(mailboxUid) {
26
+ setAddingFor(mailboxUid);
27
+ setName("");
28
+ setColor(CALENDAR_COLOR_PALETTE[0]);
29
+ }
23
30
  async function handleAdd(e) {
24
31
  e.preventDefault();
25
- if (!name.trim()) {
32
+ if (!name.trim() || !addingFor) {
26
33
  return;
27
34
  }
28
35
  setError(null);
29
36
  setSaving(true);
30
37
  try {
31
- await onAddCalendar(name.trim(), color);
38
+ await onAddCalendar(addingFor, name.trim(), color);
32
39
  setName("");
33
40
  setColor(CALENDAR_COLOR_PALETTE[0]);
34
- setAdding(false);
41
+ setAddingFor(null);
35
42
  }
36
43
  catch {
37
44
  setError("Could not create this calendar.");
@@ -40,5 +47,9 @@ export default function CalendarListSidebar({ calendars, checkedFolderUids, onTo
40
47
  setSaving(false);
41
48
  }
42
49
  }
43
- return (_jsxs("nav", { "aria-label": "My calendars", className: "p-3 border-t border-border", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "My calendars" }), _jsx("button", { type: "button", onClick: () => setAdding(true), "aria-label": "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), error && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: error }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendars.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorForFolder(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorForFolder(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), adding && (_jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-1.5 mt-2", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New calendar name", value: name, onChange: (e) => setName(e.target.value), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("div", { className: "flex items-center gap-1", role: "radiogroup", "aria-label": "Calendar color", children: CALENDAR_COLOR_PALETTE.map((swatch) => (_jsx("button", { type: "button", "aria-label": `Color ${swatch}`, "aria-pressed": color === swatch, onClick: () => setColor(swatch), className: ["w-4 h-4 rounded-full shrink-0", color === swatch ? "ring-2 ring-offset-1 ring-text" : ""].join(" "), style: { backgroundColor: swatch } }, swatch))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "submit", disabled: saving, className: "text-xs font-semibold text-primary-dark", children: "Add" }), _jsx("button", { type: "button", onClick: () => setAdding(false), className: "text-xs text-text-muted", children: "Cancel" })] })] }))] }));
50
+ const addForm = (_jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-1.5 mt-2", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New calendar name", value: name, onChange: (e) => setName(e.target.value), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("div", { className: "flex items-center gap-1", role: "radiogroup", "aria-label": "Calendar color", children: CALENDAR_COLOR_PALETTE.map((swatch) => (_jsx("button", { type: "button", "aria-label": `Color ${swatch}`, "aria-pressed": color === swatch, onClick: () => setColor(swatch), className: ["w-4 h-4 rounded-full shrink-0", color === swatch ? "ring-2 ring-offset-1 ring-text" : ""].join(" "), style: { backgroundColor: swatch } }, swatch))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "submit", disabled: saving, className: "text-xs font-semibold text-primary-dark", children: "Add" }), _jsx("button", { type: "button", onClick: () => setAddingFor(null), className: "text-xs text-text-muted", children: "Cancel" })] })] }));
51
+ return (_jsxs("nav", { "aria-label": "My calendars", className: "p-3 border-t border-border flex flex-col gap-3", children: [error && _jsx(Alert, { children: error }), mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
52
+ const title = multiple ? `${mailbox.displayName}${mailbox.ownerUserUid ? "" : " (shared)"}` : "My calendars";
53
+ return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between mb-1 gap-2", children: [_jsxs("h2", { className: "flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-text-muted min-w-0", children: [multiple && (_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: accentColorForMailbox(mailbox.uid) } })), _jsx("span", { className: "truncate", children: title })] }), _jsx("button", { type: "button", onClick: () => startAdding(mailbox.uid), "aria-label": multiple ? `Add calendar to ${mailbox.displayName}` : "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline shrink-0", children: "+" })] }), mailboxError && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendarFolders.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorFor(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorFor(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), addingFor === mailbox.uid && addForm] }, mailbox.uid));
54
+ })] }));
44
55
  }
@@ -30,8 +30,20 @@ const RESPONSE_STATUS_LABEL = {
30
30
  * occurrence offers a choice between "this event" and "the entire series" (see
31
31
  * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
32
32
  */
33
- export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, organizerAddress, occurrence, initialStart, initialEnd, onSaved, onDeleted, }) {
33
+ export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, organizerAddress, occurrence, initialStart, initialEnd, onSaved, onDeleted, }) {
34
34
  const [targetFolderUid, setTargetFolderUid] = useState(folderUid);
35
+ const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
36
+ const targetMailboxOption = mailboxOptions?.find((option) => option.mailbox.uid === targetMailboxUid);
37
+ // Create mode follows the chosen mailbox; editing an existing event always keeps the passed-in values.
38
+ const calendarChoices = !occurrence && targetMailboxOption ? targetMailboxOption.calendars : calendars;
39
+ const effectiveOrganizerAddress = !occurrence && targetMailboxOption ? targetMailboxOption.mailbox.primarySmtpAddress : organizerAddress;
40
+ function handleMailboxChange(nextMailboxUid) {
41
+ setTargetMailboxUid(nextMailboxUid);
42
+ const firstCalendar = mailboxOptions?.find((option) => option.mailbox.uid === nextMailboxUid)?.calendars[0];
43
+ if (firstCalendar) {
44
+ setTargetFolderUid(firstCalendar.uid);
45
+ }
46
+ }
35
47
  const [title, setTitle] = useState(occurrence?.title ?? "");
36
48
  const [location, setLocation] = useState(occurrence?.location ?? "");
37
49
  const [start, setStart] = useState(toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString()));
@@ -90,7 +102,7 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
90
102
  endDate: new Date(end).toISOString(),
91
103
  allDay,
92
104
  timezone,
93
- organizer: { address: organizerAddress, type: "to" },
105
+ organizer: { address: effectiveOrganizerAddress, type: "to" },
94
106
  attendees,
95
107
  recurrenceRule: recurrenceRule ?? undefined,
96
108
  reminderMinutesBeforeStart: reminderMinutes.trim() ? Number(reminderMinutes) : undefined,
@@ -101,7 +113,7 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
101
113
  setSaving(true);
102
114
  try {
103
115
  if (!occurrence) {
104
- await createCalendarEvent({ mailboxUid, folderUid: targetFolderUid, ...fields });
116
+ await createCalendarEvent({ mailboxUid: targetMailboxUid, folderUid: targetFolderUid, ...fields });
105
117
  }
106
118
  else if (occurrence.isRecurringOccurrence && editScope === "occurrence") {
107
119
  await detachOccurrence(occurrence, fields);
@@ -162,6 +174,6 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
162
174
  setResponding(false);
163
175
  }
164
176
  }
165
- return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), !occurrence && calendars && calendars.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendars.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => setAllDay(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), _jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule }) }), occurrence?.isRecurringOccurrence && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
177
+ return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), !occurrence && mailboxOptions && mailboxOptions.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "event-mailbox", children: _jsx("select", { id: "event-mailbox", className: INPUT_CLASS, value: targetMailboxUid, onChange: (e) => handleMailboxChange(e.target.value), children: mailboxOptions.map(({ mailbox }) => (_jsxs("option", { value: mailbox.uid, children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }, mailbox.uid))) }) })), !occurrence && calendarChoices && calendarChoices.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendarChoices.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => setAllDay(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), _jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule }) }), occurrence?.isRecurringOccurrence && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
166
178
  (!occurrence.isRecurringOccurrence ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete" })) : !confirmingDelete ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => setConfirmingDelete(true), children: "Delete" })) : (_jsxs("div", { className: "flex gap-2 ml-auto", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("occurrence"), children: "Delete this event" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete series" })] })))] })] }) }));
167
179
  }
@@ -1,39 +1,41 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { createContext, useContext, useEffect, useMemo, useState } from "react";
7
- import { HiOutlineBars3 } from "react-icons/hi2";
6
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
8
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
- import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
8
+ import { accentColorForMailbox, colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
10
9
  import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
11
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
11
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
13
12
  import AppShell from "../../layout/AppShell.js";
14
13
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
- const CalendarShellContext = createContext({ calendarFolders: [], mailboxes: [], reloadFolders: () => undefined });
16
- /** Reads the mailbox/folder the Calendar is currently showing, as resolved by the enclosing `CalendarShell`. */
14
+ const CalendarShellContext = createContext({
15
+ calendarFolders: [],
16
+ mailboxCalendars: [],
17
+ mailboxes: [],
18
+ reloadFolders: () => undefined,
19
+ colorFor: (folder) => colorForFolder(folder),
20
+ });
21
+ /** Reads the calendars the Calendar is currently showing, as resolved by the enclosing `CalendarShell`. */
17
22
  export function useCalendarShell() {
18
23
  return useContext(CalendarShellContext);
19
24
  }
20
25
  /**
21
- * The Calendar app's shell — structurally identical to `ContactsShell`/`TasksShell` (a mailbox has
22
- * exactly one well-known `calendar` folder, guaranteed to exist by `BaseMailboxRoute.create()`'s
23
- * eager provisioning in `@rapidmx/restapi`), just bound to a different folder type. Unlike Contacts/
24
- * Tasks, the actual view being looked at (month/week/day, and which one) is *not* shell state — it
25
- * lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between views/dates must
26
- * be instant (no full page reload) and this framework has no client-side router to make a URL-driven
27
- * approach for that free.
26
+ * The Calendar app's shell. Fetches every accessible mailbox's calendar folders in parallel so shared
27
+ * mailboxes' calendars show alongside the caller's own, each mailbox color-coded (see `colorFor`) - there
28
+ * is no mailbox switcher. The actual view being looked at (month/week/day, and which one) is *not* shell
29
+ * state — it lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between
30
+ * views/dates must be instant (no full page reload) and this framework has no client-side router.
28
31
  */
29
32
  export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
30
33
  const [status, setStatus] = useState("checking");
31
34
  const [error, setError] = useState(null);
32
35
  const [mailboxes, setMailboxes] = useState([]);
33
- const [folders, setFolders] = useState([]);
34
- const [folderError, setFolderError] = useState(null);
36
+ const [mailboxCalendars, setMailboxCalendars] = useState([]);
35
37
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
36
- const [drawerOpen, setDrawerOpen] = useState(false);
38
+ const [folderRefreshToken, setFolderRefreshToken] = useState(0);
37
39
  useEffect(() => {
38
40
  setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
39
41
  }, []);
@@ -51,46 +53,52 @@ export default function CalendarShell({ userUid, authServerUrl, impersonating, i
51
53
  setStatus("error");
52
54
  });
53
55
  }, [userUid]);
56
+ const ownMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
54
57
  const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
55
- mailboxes[0]?.uid;
56
- const [folderRefreshToken, setFolderRefreshToken] = useState(0);
58
+ ownMailboxUid;
57
59
  useEffect(() => {
58
- if (!mailboxUid) {
59
- setFolders([]);
60
+ if (mailboxes.length === 0) {
61
+ setMailboxCalendars([]);
60
62
  return;
61
63
  }
62
- setFolderError(null);
63
- listFolders(mailboxUid)
64
- .then(setFolders)
65
- .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's calendar folder."));
66
- }, [mailboxUid, folderRefreshToken]);
67
- const calendarFolders = useMemo(() => folders.filter((f) => f.type === "calendar"), [folders]);
68
- const folderUid = calendarFolders[0]?.uid;
69
- const contextValue = useMemo(() => ({ mailboxUid, folderUid, calendarFolders, mailboxes, reloadFolders: () => setFolderRefreshToken((t) => t + 1) }), [mailboxUid, folderUid, calendarFolders, mailboxes]);
64
+ let cancelled = false;
65
+ Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
66
+ .then((folders) => ({ mailbox, calendarFolders: folders.filter((f) => f.type === "calendar") }))
67
+ .catch((err) => ({
68
+ mailbox,
69
+ calendarFolders: [],
70
+ error: err instanceof ApiRequestError ? err.message : "Could not load this mailbox's calendar folder.",
71
+ })))).then((result) => {
72
+ if (!cancelled) {
73
+ setMailboxCalendars(result);
74
+ }
75
+ });
76
+ return () => {
77
+ cancelled = true;
78
+ };
79
+ }, [mailboxes, folderRefreshToken]);
80
+ const calendarFolders = useMemo(() => mailboxCalendars.flatMap((mc) => mc.calendarFolders), [mailboxCalendars]);
81
+ const folderUid = calendarFolders.find((f) => f.mailboxUid === mailboxUid)?.uid;
82
+ const colorFor = useCallback((folder) => colorForFolder(folder, folder.mailboxUid === ownMailboxUid ? undefined : accentColorForMailbox(folder.mailboxUid)), [ownMailboxUid]);
83
+ const reloadFolders = useCallback(() => setFolderRefreshToken((t) => t + 1), []);
84
+ const contextValue = useMemo(() => ({ mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor }), [mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor]);
70
85
  // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
71
86
  // for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
72
- if (userUid && status === "ready" && !mailboxUid) {
87
+ if (userUid && status === "ready" && mailboxes.length === 0) {
73
88
  return _jsx(MailboxProvisioning, {});
74
89
  }
75
90
  let inner = null;
76
91
  if (userUid && status === "checking") {
77
92
  // Renders immediately (no network round trip needed) so switching into Calendar never shows a
78
- // blank pane while `listMailboxes()` is in flight — the actual sidebar (mini date-picker +
79
- // calendar list) lives in `apps/www/calendar/index.tsx`'s own content, so this mimics its rough
80
- // shape rather than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
93
+ // blank pane while `listMailboxes()` is in flight — mimics the rough shape of the real sidebar
94
+ // (mini date-picker + calendar list) that `apps/www/calendar/index.tsx` renders.
81
95
  inner = (_jsxs("div", { className: "w-56 shrink-0 bg-surface border-r border-border p-3 flex flex-col gap-4", children: [_jsx(Skeleton, { height: "h-40", className: "rounded-md" }), _jsx(SkeletonList, { count: 4 })] }));
82
96
  }
83
97
  else if (userUid && status === "error") {
84
98
  inner = (_jsx("div", { className: "flex-1 flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
85
99
  }
86
100
  else if (userUid && status === "ready") {
87
- // A function, not a plain JSX constant — see MailShell/ContactsShell's identical comment: it's
88
- // rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the
89
- // `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
90
- const mailboxSwitcher = (idPrefix) => mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-calendar-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-calendar-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
91
- window.location.href = `/calendar?mailboxUid=${encodeURIComponent(e.target.value)}`;
92
- }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] }));
93
- inner = (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsx("div", { className: "hidden md:block w-56 shrink-0 bg-surface border-r border-border p-3", children: mailboxSwitcher("desktop") })), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsxs("div", { className: "flex flex-col gap-3", children: [mailboxSwitcher("mobile"), folderError && _jsx(Alert, { children: folderError })] }) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [(mailboxes.length > 1 || folderError) && (_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), folderError && (_jsx("div", { className: "hidden md:block p-3", children: _jsx(Alert, { children: folderError }) })), _jsx(CalendarShellContext.Provider, { value: contextValue, children: children })] })] }));
101
+ inner = (_jsx("div", { className: "flex-1 min-w-0 flex flex-col", children: _jsx(CalendarShellContext.Provider, { value: contextValue, children: children }) }));
94
102
  }
95
103
  return (_jsx(AppShell, { active: "calendar", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }));
96
104
  }
@@ -9,6 +9,7 @@ import { createContact, updateContact, } from "@rapidmx/react-shared/contacts/co
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
11
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
+ import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
12
13
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
14
  const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
15
  const ADDRESS_KINDS = ["home", "work", "other"];
@@ -17,7 +18,8 @@ const ADDRESS_KINDS = ["home", "work", "other"];
17
18
  * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
18
19
  * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
19
20
  */
20
- export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, onCancel }) {
21
+ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }) {
22
+ const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
21
23
  const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
22
24
  const [givenName, setGivenName] = useState(contact?.givenName ?? "");
23
25
  const [surname, setSurname] = useState(contact?.surname ?? "");
@@ -69,9 +71,18 @@ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, o
69
71
  phones,
70
72
  addresses: address ? [address] : [],
71
73
  };
72
- const saved = contact
73
- ? await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input })
74
- : await createContact({ mailboxUid: mailboxUid, folderUid: folderUid, ...input });
74
+ let saved;
75
+ if (contact) {
76
+ saved = await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input });
77
+ }
78
+ else {
79
+ const targetFolderUid = targetMailboxUid === mailboxUid ? folderUid : await findWellKnownFolderUid(targetMailboxUid, "contacts");
80
+ if (!targetFolderUid) {
81
+ setError("That mailbox has no Contacts folder.");
82
+ return;
83
+ }
84
+ saved = await createContact({ mailboxUid: targetMailboxUid, folderUid: targetFolderUid, ...input });
85
+ }
75
86
  onSaved(saved);
76
87
  }
77
88
  catch (err) {
@@ -81,5 +92,5 @@ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, o
81
92
  setSaving(false);
82
93
  }
83
94
  }
84
- return (_jsxs("form", { onSubmit: handleSubmit, className: "max-w-xl flex flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: favorite, onChange: (e) => setFavorite(e.target.checked) }), "Favorite"] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: address ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => setAddress({ ...address, street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => setAddress({ ...address, city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => setAddress({ ...address, state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => setAddress({ ...address, postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => setAddress({ ...address, country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => setAddress({ ...address, type: e.target.value }), "aria-label": "Address type", children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => setAddress(null), className: "self-start text-xs font-medium text-danger hover:underline", children: "Remove address" })] })) : (_jsx("button", { type: "button", onClick: () => setAddress({ type: "home" }), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
95
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "max-w-xl flex flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: favorite, onChange: (e) => setFavorite(e.target.checked) }), "Favorite"] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: address ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => setAddress({ ...address, street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => setAddress({ ...address, city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => setAddress({ ...address, state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => setAddress({ ...address, postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => setAddress({ ...address, country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => setAddress({ ...address, type: e.target.value }), "aria-label": "Address type", children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => setAddress(null), className: "self-start text-xs font-medium text-danger hover:underline", children: "Remove address" })] })) : (_jsx("button", { type: "button", onClick: () => setAddress({ type: "home" }), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
85
96
  }
@@ -4,9 +4,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import "../../styles/app.css";
7
- import { useState } from "react";
7
+ import { useState, useEffect } from "react";
8
8
  import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
9
9
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
10
+ import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
10
11
  import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
11
12
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
12
13
  import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeout.js";
@@ -14,6 +15,8 @@ import ComposeProvider from "../mail/compose/ComposeContext.js";
14
15
  import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
15
16
  import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
16
17
  import UserMenu from "./UserMenu.js";
18
+ import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
19
+ import { destroyAllLocalIndexes } from "../../search/localIndexRpcClient.js";
17
20
  export const APPS = [
18
21
  { id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
19
22
  { id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
@@ -43,7 +46,27 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
43
46
  // Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
44
47
  // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
45
48
  useIdleKeyTimeout();
49
+ // An administrator on a server that hasn't finished first-run setup is sent to the setup wizard. Everyone else
50
+ // gets a 403 from the setup status check (it's admin-only), which is ignored along with any other failure.
51
+ useEffect(() => {
52
+ if (!userUid || impersonating) {
53
+ return;
54
+ }
55
+ getSetupStatus()
56
+ .then((status) => {
57
+ if (status.required) {
58
+ window.location.href = "/admin/setup";
59
+ }
60
+ })
61
+ .catch(() => undefined);
62
+ }, [userUid, impersonating]);
46
63
  function handleSignOut() {
64
+ // Best-effort, fire-and-forget: the Tier 2 local index MUST be destroyed on explicit logout, the
65
+ // same as unlocked keys themselves (spec §11) - this component has no specific mailboxUid of its
66
+ // own to pass (it's shared by every app, not just Mail), so this destroys every mailbox's index
67
+ // the session has open rather than needing one threaded through. Not awaited - the navigation
68
+ // below shouldn't wait on it, and destroyAllLocalIndexes() never throws either way.
69
+ void destroyAllLocalIndexes();
47
70
  window.location.href = authServerUrl ?? "/";
48
71
  }
49
72
  async function handleStopImpersonating() {
@@ -63,10 +86,10 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
63
86
  if (!userUid) {
64
87
  return _jsx("div", { className: "min-h-screen" });
65
88
  }
66
- return (_jsx(ComposeProvider, { children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), APPS.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
67
- "w-10 h-10 flex items-center justify-center rounded-sm",
68
- id === active
69
- ? "bg-primary/10 text-primary-dark"
70
- : "text-text-muted hover:bg-surface-alt hover:text-text",
71
- ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: APPS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: ACTIVE_LABELS[active] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showAdminLink: trusted, showSettingsLink: true })] }), _jsx("div", { className: "flex-1 flex min-h-0 pb-14 md:pb-0", children: children })] })] }), _jsx(BrandingFooter, { branding: branding })] }) }));
89
+ return (_jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), APPS.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
90
+ "w-10 h-10 flex items-center justify-center rounded-sm",
91
+ id === active
92
+ ? "bg-primary/10 text-primary-dark"
93
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
94
+ ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: APPS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: ACTIVE_LABELS[active] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showAdminLink: trusted, showSettingsLink: true })] }), _jsx("div", { className: "flex-1 flex min-h-0 pb-14 md:pb-0", children: children })] })] }), _jsx(BrandingFooter, { branding: branding })] }) }) }));
72
95
  }
@@ -48,13 +48,14 @@ async function provisionEncryptionKey(mailboxUid, mailboxAddress, password) {
48
48
  * `children` unchanged rather than blocking mail entirely - encryption is optional and gradual by design
49
49
  * (the spec's own "near zero frictionless experience" goal), not a hard prerequisite for reading mail.
50
50
  */
51
- export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxKeys, children }) {
51
+ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxKeys, blocking = true, children, }) {
52
52
  const [status, setStatus] = useState(mailboxUid ? "checking" : "ready");
53
53
  const [password, setPassword] = useState("");
54
54
  const [confirmPassword, setConfirmPassword] = useState("");
55
55
  const [error, setError] = useState(null);
56
56
  const [recoveryCodes, setRecoveryCodes] = useState([]);
57
57
  const [codesSaved, setCodesSaved] = useState(false);
58
+ const [codesCopied, setCodesCopied] = useState(false);
58
59
  useEffect(() => {
59
60
  if (!mailboxUid) {
60
61
  return;
@@ -124,10 +125,21 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
124
125
  setStatus("setup_password");
125
126
  }
126
127
  }
128
+ async function handleCopyCodes() {
129
+ try {
130
+ await navigator.clipboard.writeText(recoveryCodes.join("\n"));
131
+ setCodesCopied(true);
132
+ setTimeout(() => setCodesCopied(false), 2000);
133
+ }
134
+ catch {
135
+ // Clipboard access can be denied by the browser - the codes are still selectable/copyable by
136
+ // hand from the list below.
137
+ }
138
+ }
127
139
  if (status === "checking") {
128
140
  return null;
129
141
  }
130
- if (status === "unlock" || status === "unlocking") {
142
+ if ((status === "unlock" || status === "unlocking") && blocking) {
131
143
  const unlocking = status === "unlocking";
132
144
  return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Unlock your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Enter your encryption password to unlock signing and reading protected mail this session." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleUnlock, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-unlock-password", children: _jsx("input", { id: "key-unlock-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: unlocking, autoComplete: "current-password" }) }), _jsx(Button, { type: "submit", loading: unlocking, disabled: unlocking, children: "Unlock" })] })] }) }));
133
145
  }
@@ -136,7 +148,7 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
136
148
  return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Protect your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Choose a password to protect your encryption keys. This is separate from your sign-in password and is never sent to the server." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSetPassword, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-enrollment-password", children: _jsx("input", { id: "key-enrollment-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(FormField, { label: "Confirm password", htmlFor: "key-enrollment-password-confirm", children: _jsx("input", { id: "key-enrollment-password-confirm", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", loading: enrolling, disabled: enrolling, children: "Continue" })] })] }) }));
137
149
  }
138
150
  if (status === "show_recovery_codes") {
139
- return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Save your recovery codes" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "If you lose your password, these codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again." }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-5 font-mono text-sm", children: recoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setStatus("ready"), children: "Continue" })] }) }));
151
+ return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Save your recovery codes" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "If you lose your password, these codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again." }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-3 font-mono text-sm", children: recoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-5", onClick: handleCopyCodes, children: codesCopied ? "Copied" : "Copy codes to clipboard" }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setStatus("ready"), children: "Continue" })] }) }));
140
152
  }
141
153
  return _jsx(_Fragment, { children: children });
142
154
  }
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { createContext, useCallback, useContext, useState } from "react";
7
+ import { getUnlockedKeys, unlockWithPassword } from "@rapidmx/react-shared/crypto/keySession.js";
8
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
+ const UnlockPromptContext = createContext(null);
13
+ /**
14
+ * The on-demand counterpart to `KeyEnrollmentGate`'s blocking, first-sign-in-only provisioning flow.
15
+ * Per `specs/end-to-end_encryption.md`, unlocking is only actually *required* to (1) sign or encrypt an
16
+ * outgoing message, (2) read an already-encrypted message, or (3) change encryption settings that touch
17
+ * the master key - not merely to have a mailbox with an existing vault resolve. Mounted once in
18
+ * `AppShell.tsx` (shared by every top-level app shell: Mail, Calendar, Contacts, Tasks, Settings), so
19
+ * any of those three call sites (`ComposeWindow`'s sign/encrypt toggles, `MessageDetailPane`'s encrypted-
20
+ * message view, `Settings > Encryption`) can request an unlock right where it's needed via
21
+ * `useUnlockPrompt()`, instead of the whole app being blocked behind an unlock screen the moment a
22
+ * mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
23
+ * `false` for exactly this reason.
24
+ */
25
+ export function UnlockPromptProvider({ children }) {
26
+ const [pending, setPending] = useState(null);
27
+ const [password, setPassword] = useState("");
28
+ const [error, setError] = useState(null);
29
+ const [unlocking, setUnlocking] = useState(false);
30
+ const requestUnlock = useCallback((mailboxUid, mailboxKeys) => {
31
+ const existing = getUnlockedKeys(mailboxUid);
32
+ if (existing) {
33
+ return Promise.resolve(existing);
34
+ }
35
+ return new Promise((resolve, reject) => {
36
+ setPassword("");
37
+ setError(null);
38
+ setPending({ mailboxUid, mailboxKeys, resolve, reject });
39
+ });
40
+ }, []);
41
+ function handleCancel() {
42
+ pending?.reject(new Error("Unlock cancelled."));
43
+ setPending(null);
44
+ }
45
+ async function handleSubmit(e) {
46
+ e.preventDefault();
47
+ // Only reachable via the form below, which never renders while `pending` is null.
48
+ const request = pending;
49
+ setError(null);
50
+ setUnlocking(true);
51
+ try {
52
+ await unlockWithPassword(request.mailboxUid, request.mailboxKeys, password);
53
+ // Always reachable: unlockWithPassword() just populated keySession.ts's session store for
54
+ // this exact mailboxUid, or threw before we get here.
55
+ const unlocked = getUnlockedKeys(request.mailboxUid);
56
+ request.resolve(unlocked);
57
+ setPending(null);
58
+ }
59
+ catch {
60
+ // Deliberately generic - see KeyEnrollmentGate's identical handleUnlock() for why.
61
+ setError("Incorrect password.");
62
+ }
63
+ finally {
64
+ setUnlocking(false);
65
+ }
66
+ }
67
+ return (_jsxs(UnlockPromptContext.Provider, { value: { requestUnlock }, children: [children, _jsxs(Modal, { open: !!pending, onClose: handleCancel, title: "Unlock your mailbox", children: [_jsx("p", { className: "text-sm text-text-muted mb-5", children: "Enter your encryption password to continue." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "unlock-prompt-password", children: _jsx("input", { id: "unlock-prompt-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: unlocking, autoComplete: "current-password", autoFocus: true }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", loading: unlocking, disabled: unlocking, children: "Unlock" }), _jsx(Button, { type: "button", variant: "secondary", onClick: handleCancel, disabled: unlocking, children: "Cancel" })] })] })] })] }));
68
+ }
69
+ /** Throws outside an `<UnlockPromptProvider>` - every app shell mounts one (see `AppShell.tsx`), so
70
+ * reaching this from any real page is a wiring bug, not a runtime condition to degrade gracefully from. */
71
+ export function useUnlockPrompt() {
72
+ const ctx = useContext(UnlockPromptContext);
73
+ if (!ctx) {
74
+ throw new Error("useUnlockPrompt() must be used within an UnlockPromptProvider (see AppShell.tsx).");
75
+ }
76
+ return ctx;
77
+ }