@rapidmx/web-client 0.14.1 → 0.15.1

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 (171) hide show
  1. package/apps/admin/domains/[uid].tsx +3 -2
  2. package/apps/shared/appearance/AppearanceForm.tsx +9 -1
  3. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  4. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
  6. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
  8. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  9. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  10. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  11. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  12. package/apps/shared/components/calendar/EventEditor.tsx +640 -0
  13. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  14. package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
  15. package/apps/shared/components/calendar/EventModal.tsx +126 -866
  16. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  17. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  18. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  19. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  20. package/apps/shared/components/calendar/MonthView.tsx +15 -3
  21. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  22. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  23. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  24. package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
  25. package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
  26. package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
  27. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  28. package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
  29. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  30. package/apps/shared/components/calendar/eventForm.ts +108 -0
  31. package/apps/shared/components/calendar/eventFormat.ts +183 -0
  32. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  33. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  34. package/apps/shared/components/layout/UserMenu.tsx +80 -28
  35. package/apps/shared/components/mail/ConversationList.tsx +100 -48
  36. package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
  37. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  38. package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
  39. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  40. package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
  41. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  42. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  43. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  44. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  45. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  46. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
  48. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  49. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  50. package/apps/shared/gestures/useSwipe.ts +114 -0
  51. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  52. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  53. package/apps/shared/mail/newMailNotifications.ts +171 -183
  54. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  55. package/apps/shared/notifications/preferences.ts +36 -0
  56. package/apps/shared/notifications/store.ts +560 -551
  57. package/apps/www/_routes.ts +1 -0
  58. package/apps/www/calendar/index.tsx +139 -37
  59. package/apps/www/index.tsx +273 -126
  60. package/apps/www/messages/[uid].tsx +31 -4
  61. package/apps/www/settings/profile/index.tsx +248 -0
  62. package/dist/apps/admin/domains/[uid].js +4 -2
  63. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  64. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  65. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  66. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  67. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  68. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  69. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  70. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
  71. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  72. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  73. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  74. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  75. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  76. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  77. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  78. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  79. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  80. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  81. package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
  82. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  83. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  84. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
  85. package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
  86. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  87. package/dist/apps/shared/components/calendar/EventModal.js +57 -459
  88. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  89. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  90. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  91. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  92. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  93. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  94. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  95. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  96. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  97. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  98. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  99. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  100. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  101. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  102. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  103. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  104. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  105. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  106. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  107. package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
  108. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  109. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  110. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  111. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  112. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  113. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  114. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  115. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  116. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  117. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  118. package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
  119. package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
  120. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  121. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  122. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  123. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
  125. package/dist/apps/shared/components/layout/UserMenu.js +70 -23
  126. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  127. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  129. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  130. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  131. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  132. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  133. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  134. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
  135. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  136. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  137. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  138. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  139. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  140. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  141. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  142. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  143. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  144. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  145. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  146. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  147. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  148. package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
  149. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  150. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  151. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  152. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  153. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  154. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  155. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  156. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  157. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  158. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  159. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  160. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  161. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  162. package/dist/apps/shared/notifications/preferences.js +36 -0
  163. package/dist/apps/shared/notifications/store.d.ts +1 -0
  164. package/dist/apps/shared/notifications/store.js +7 -23
  165. package/dist/apps/www/_routes.js +1 -0
  166. package/dist/apps/www/calendar/index.js +76 -6
  167. package/dist/apps/www/index.js +140 -39
  168. package/dist/apps/www/messages/[uid].js +13 -5
  169. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  170. package/dist/apps/www/settings/profile/index.js +124 -0
  171. package/package.json +2 -2
@@ -10,13 +10,16 @@ import { Label, listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
10
10
  import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
11
11
  import { useMarkMessageRead } from "../../shared/mail/useMarkMessageRead.js";
12
12
  import MailShell, { MailShellProps, useMailShell } from "../../shared/components/mail/layout/MailShell.js";
13
- import { LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
13
+ import { LazyConversationThreadPane, LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
14
+ import { useLocationSearch } from "../../shared/navigation/AppRouter.js";
14
15
  import { ReadingPaneSkeleton } from "../../shared/components/mail/reading/MessageCard.js";
15
16
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
16
17
 
17
18
  /**
18
19
  * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/index.tsx` keeps its existing
19
- * inline reading pane and never navigates here; see that file's `handleSelect`.
20
+ * inline reading pane and never navigates here; see that file's `handleSelect`. A row of the conversation list
21
+ * comes with `?conversation=<id>` (`handleOpenConversation`), and then this page is the whole thread, opened at this
22
+ * message, as the desktop's reading pane is; without it (a flat list, a link to a message) it is that one message.
20
23
  */
21
24
  function MessageDetailPage(props: MailShellProps & { params: { uid: string } }) {
22
25
  return (
@@ -32,6 +35,7 @@ function MessageDetailContent({ uid }: { uid: string }) {
32
35
  const [loading, setLoading] = useState(true);
33
36
  const [error, setError] = useState<string | null>(null);
34
37
  const [labels, setLabels] = useState<Label[]>([]);
38
+ const conversationId: string | null = new URLSearchParams(useLocationSearch()).get("conversation");
35
39
 
36
40
  useEffect(() => {
37
41
  setLoading(true);
@@ -63,8 +67,9 @@ function MessageDetailContent({ uid }: { uid: string }) {
63
67
  };
64
68
  }, [messageMailboxUid]);
65
69
 
66
- const attachments = useMessageAttachments(message);
67
- useMarkMessageRead(message, setMessage);
70
+ // A thread loads, marks read and fetches the attachments of each message it opens itself.
71
+ const attachments = useMessageAttachments(conversationId ? null : message);
72
+ useMarkMessageRead(conversationId ? null : message, setMessage);
68
73
 
69
74
  if (loading) {
70
75
  // The pane's own frame - a header card and a message card - rather than a line of text: what is coming is known, only not yet its contents.
@@ -82,6 +87,28 @@ function MessageDetailContent({ uid }: { uid: string }) {
82
87
  const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
83
88
  const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
84
89
  const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
90
+ if (conversationId) {
91
+ return (
92
+ // The thread's own height, as the single message's: it scrolls inside the page rather than growing it.
93
+ <div className="flex flex-col h-full min-h-0">
94
+ <a href={backHref} className="text-sm text-primary-dark hover:underline block px-4 pt-3">
95
+ &larr; Back to messages
96
+ </a>
97
+ <LazyConversationThreadPane
98
+ conversation={{ conversationId, subject: message.subject, messageCount: 1 }}
99
+ selectedUid={message.uid}
100
+ mailboxUid={message.mailboxUid}
101
+ folders={folders}
102
+ labels={labels}
103
+ shortcuts
104
+ onMessagePatched={() => undefined}
105
+ onMessageRemoved={() => undefined}
106
+ onLabelCreated={(label) => setLabels((prev) => [...prev, label])}
107
+ onFolderCreated={onFolderCreated}
108
+ />
109
+ </div>
110
+ );
111
+ }
85
112
  return (
86
113
  <LazyMessageDetailPane
87
114
  shortcuts
@@ -0,0 +1,248 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { routedPage } from "../../_routedPage.js";
6
+ import React, { FormEvent, useMemo, useState } from "react";
7
+ import { FreeBusyVisibility, freeBusyVisibilityOf, isSharedWithMe, updateMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { DEFAULT_TIME_ZONE, deviceTimeZone, timeZoneOptions } from "@rapidmx/react-shared/util/timeZone.js";
9
+ import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
+ import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
13
+
14
+ export type SettingsProfilePageProps = Omit<SettingsShellProps, "active">;
15
+
16
+ const INPUT_CLASS =
17
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
18
+ const SELECT_CLASS =
19
+ "text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
20
+
21
+ /** Who may see when this mailbox is busy, as the Profile page words it: the label of each choice and the one line that says what it means. */
22
+ const FREE_BUSY_CHOICES: { value: FreeBusyVisibility; label: string; help: string }[] = [
23
+ { value: "domain", label: "Everyone on my domain", help: "People with a mailbox on your domain can see when you are busy." },
24
+ { value: "shared", label: "Only people I've shared my calendar with", help: "Only people you have given access to this mailbox or its calendars can see when you are busy." },
25
+ { value: "nobody", label: "Nobody", help: "Nobody else can see when you are busy; people looking for a time see your availability as hidden." },
26
+ { value: "everyone", label: "Everyone on this server", help: "Anyone signed in to this server can see when you are busy." },
27
+ ];
28
+
29
+ /** The longest display name the server accepts. */
30
+ const MAX_DISPLAY_NAME_LENGTH = 255;
31
+
32
+ /**
33
+ * Why `displayName` can't be saved as typed, or `undefined` if it can. The server refuses the same things (400): the name
34
+ * is shown as the sender name on mail from this mailbox, so it must not look like an address (the fullwidth and small
35
+ * look-alike @ signs included) or carry a line break.
36
+ */
37
+ function displayNameProblem(displayName: string): string | undefined {
38
+ if (!displayName) {
39
+ return "Enter a display name.";
40
+ }
41
+ if (/[@@﹫\r\n]/.test(displayName)) {
42
+ return "A display name can't contain \"@\" (or a look-alike) or line breaks - it is shown as the sender name on mail you send.";
43
+ }
44
+ if (displayName.length > MAX_DISPLAY_NAME_LENGTH) {
45
+ return `A display name can be at most ${MAX_DISPLAY_NAME_LENGTH} characters.`;
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function SettingsProfilePage(props: SettingsProfilePageProps) {
51
+ return (
52
+ <SettingsShell {...props} active="profile">
53
+ <ProfileContent />
54
+ </SettingsShell>
55
+ );
56
+ }
57
+
58
+ function ProfileContent() {
59
+ const { mailboxUid, mailboxes } = useSettingsShell();
60
+ // `SettingsShell` only ever renders its children once `mailboxes` has loaded and `mailboxUid` has
61
+ // resolved to one of them — same established non-null pattern as `apps/www/settings/auto-reply/index.tsx`.
62
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
63
+
64
+ const deviceZone = useMemo(() => deviceTimeZone(), []);
65
+ // What the server holds, which is what "changed" is measured against - moved along by each successful save.
66
+ const [savedName, setSavedName] = useState(mailbox.displayName);
67
+ const [savedZone, setSavedZone] = useState(mailbox.timezone);
68
+ const [displayName, setDisplayName] = useState(mailbox.displayName);
69
+ // "UTC" is the server's placeholder for a mailbox nobody chose a zone for, not a choice anyone made, so this device's
70
+ // zone is offered instead. It stays unsaved until Save, like any other edit.
71
+ const [timezone, setTimezone] = useState(() =>
72
+ mailbox.timezone === DEFAULT_TIME_ZONE && deviceZone !== DEFAULT_TIME_ZONE ? deviceZone : mailbox.timezone,
73
+ );
74
+ const [savedFreeBusy, setSavedFreeBusy] = useState(freeBusyVisibilityOf(mailbox));
75
+ const [freeBusy, setFreeBusy] = useState(savedFreeBusy);
76
+ // Only the mailbox's owner may change who sees its free/busy: on one shared with you the setting is shown, not offered.
77
+ const canChangeFreeBusy = !isSharedWithMe(mailbox);
78
+ const [nameError, setNameError] = useState<string | null>(null);
79
+ // See auto-reply/index.tsx's identical note - later saves must carry the version the previous save returned.
80
+ const [version, setVersion] = useState(mailbox.version);
81
+ const [saving, setSaving] = useState(false);
82
+ const [saved, setSaved] = useState(false);
83
+
84
+ const zones = useMemo(() => timeZoneOptions(savedZone, deviceZone, timezone), [savedZone, deviceZone, timezone]);
85
+ const zoneWasPreselected = savedZone === DEFAULT_TIME_ZONE && deviceZone !== DEFAULT_TIME_ZONE && timezone === deviceZone;
86
+
87
+ async function handleSubmit(e: FormEvent) {
88
+ e.preventDefault();
89
+ setSaved(false);
90
+
91
+ const name = displayName.trim();
92
+ const problem = displayNameProblem(name);
93
+ if (problem) {
94
+ setNameError(problem);
95
+ return;
96
+ }
97
+ setNameError(null);
98
+ setDisplayName(name);
99
+
100
+ const nameChanged = name !== savedName;
101
+ const zoneChanged = timezone !== savedZone;
102
+ const freeBusyChanged = canChangeFreeBusy && freeBusy !== savedFreeBusy;
103
+ if (!nameChanged && !zoneChanged && !freeBusyChanged) {
104
+ setSaved(true);
105
+ return;
106
+ }
107
+
108
+ setSaving(true);
109
+ try {
110
+ const updated = await updateMailbox({
111
+ uid: mailbox.uid,
112
+ version,
113
+ ...(nameChanged && { displayName: name }),
114
+ ...(zoneChanged && { timezone }),
115
+ ...(freeBusyChanged && { freeBusyVisibility: freeBusy }),
116
+ });
117
+ setVersion(updated.version);
118
+ setSavedName(name);
119
+ setSavedZone(timezone);
120
+ setSavedFreeBusy(freeBusy);
121
+ setSaved(true);
122
+ } catch (err) {
123
+ notifyApiError(err, "Couldn't save your profile");
124
+ } finally {
125
+ setSaving(false);
126
+ }
127
+ }
128
+
129
+ return (
130
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
131
+ <div className="max-w-xl">
132
+ <h1 className="text-lg font-bold tracking-tight mb-1">Profile</h1>
133
+ <p className="text-sm text-text-muted mb-4">
134
+ How this mailbox presents itself to the people you write to, and where you are.
135
+ </p>
136
+
137
+ {saved && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
138
+
139
+ <form onSubmit={handleSubmit} className="flex flex-col">
140
+ <FormField label="Display name" htmlFor="profile-display-name">
141
+ <input
142
+ id="profile-display-name"
143
+ type="text"
144
+ className={INPUT_CLASS}
145
+ value={displayName}
146
+ onChange={(e) => {
147
+ setDisplayName(e.target.value);
148
+ setNameError(null);
149
+ setSaved(false);
150
+ }}
151
+ aria-invalid={nameError ? true : undefined}
152
+ aria-describedby={nameError ? "profile-display-name-error" : "profile-display-name-help"}
153
+ />
154
+ {nameError && (
155
+ <p id="profile-display-name-error" role="alert" className="mt-1 text-xs text-danger">
156
+ {nameError}
157
+ </p>
158
+ )}
159
+ <p id="profile-display-name-help" className="mt-1 text-xs text-text-muted">
160
+ The sender name on mail you send, shown to recipients beside your address (for example
161
+ &ldquo;Jane Doe&rdquo;). It can&rsquo;t contain an &ldquo;@&rdquo;.
162
+ </p>
163
+ </FormField>
164
+
165
+ <FormField label="Time zone" htmlFor="profile-time-zone">
166
+ <div className="flex flex-wrap items-center gap-2">
167
+ <select
168
+ id="profile-time-zone"
169
+ className={SELECT_CLASS}
170
+ value={timezone}
171
+ onChange={(e) => {
172
+ setTimezone(e.target.value);
173
+ setSaved(false);
174
+ }}
175
+ aria-describedby="profile-time-zone-help"
176
+ >
177
+ {zones.map((zone) => (
178
+ <option key={zone} value={zone}>
179
+ {zone}
180
+ </option>
181
+ ))}
182
+ </select>
183
+ {timezone !== deviceZone && (
184
+ <Button
185
+ type="button"
186
+ variant="text"
187
+ className="!w-auto"
188
+ onClick={() => {
189
+ setTimezone(deviceZone);
190
+ setSaved(false);
191
+ }}
192
+ >
193
+ Use this device&rsquo;s time zone ({deviceZone})
194
+ </Button>
195
+ )}
196
+ </div>
197
+ {zoneWasPreselected && (
198
+ <p className="mt-1 text-xs text-text-muted">
199
+ Your mailbox has no time zone chosen yet; this device&rsquo;s is preselected. Save to keep it.
200
+ </p>
201
+ )}
202
+ <p id="profile-time-zone-help" className="mt-1 text-xs text-text-muted">
203
+ How calendar times and reminders are shown and scheduled for this mailbox, such as when an
204
+ event or a reminder falls.
205
+ </p>
206
+ </FormField>
207
+
208
+ <FormField label="Free/busy visibility" htmlFor="profile-free-busy">
209
+ <select
210
+ id="profile-free-busy"
211
+ className={SELECT_CLASS}
212
+ value={freeBusy}
213
+ disabled={!canChangeFreeBusy}
214
+ onChange={(e) => {
215
+ setFreeBusy(e.target.value as FreeBusyVisibility);
216
+ setSaved(false);
217
+ }}
218
+ aria-describedby="profile-free-busy-help"
219
+ >
220
+ {FREE_BUSY_CHOICES.map((choice) => (
221
+ <option key={choice.value} value={choice.value}>
222
+ {choice.label}
223
+ </option>
224
+ ))}
225
+ </select>
226
+ <p id="profile-free-busy-help" className="mt-1 text-xs text-text-muted">
227
+ {FREE_BUSY_CHOICES.find((choice) => choice.value === freeBusy)!.help}
228
+ </p>
229
+ <p className="mt-1 text-xs text-text-muted">
230
+ Who can see the hours you are busy when they look for a time to meet - never the title, place or guests of an event.
231
+ </p>
232
+ {!canChangeFreeBusy && (
233
+ <p className="mt-1 text-xs text-text-muted">This mailbox is shared with you: only its owner can change who sees its free/busy times.</p>
234
+ )}
235
+ </FormField>
236
+
237
+ <div>
238
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
239
+ Save
240
+ </Button>
241
+ </div>
242
+ </form>
243
+ </div>
244
+ </div>
245
+ );
246
+ }
247
+
248
+ export default routedPage("/settings/profile", SettingsProfilePage);
@@ -68,8 +68,10 @@ function DomainDetailContent({ uid }) {
68
68
  setSavingAlias(false);
69
69
  }
70
70
  }
71
- return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS, value: aliasInput ?? "", onChange: (e) => {
71
+ return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS,
72
+ // Set in the same update as `domain` (see `handleLoaded()`), which is what this section waits for.
73
+ value: aliasInput, onChange: (e) => {
72
74
  setAliasInput(e.target.value);
73
75
  setAliasSaved(false);
74
- }, placeholder: "e.g. powerlevel.gg" }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: savingAlias, disabled: savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? ""), onClick: handleSaveAlias, children: "Save" }), aliasSaved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] })), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
76
+ }, placeholder: "e.g. powerlevel.gg" }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: savingAlias, disabled: savingAlias || aliasInput === (domain.aliasOf ?? ""), onClick: handleSaveAlias, children: "Save" }), aliasSaved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] })), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
75
77
  }
@@ -54,7 +54,15 @@ export default function AppearanceForm() {
54
54
  const colors = prefs.colors;
55
55
  // The kind the radio shows: chosen before it can take effect ("Image" is chosen before there is one).
56
56
  const [kind, setKind] = useState(background?.kind ?? "none");
57
- useEffect(() => setKind(background?.kind ?? "none"), [background?.kind]);
57
+ // Follows a change of the stored kind (the server's answer, another tab, a reset) - not the first render, which `useState()` above
58
+ // already started from: that effect can run after a choice made right after the page appeared, and would put the old kind back.
59
+ const shownKind = useRef(background?.kind);
60
+ useEffect(() => {
61
+ if (shownKind.current !== background?.kind) {
62
+ shownKind.current = background?.kind;
63
+ setKind(background?.kind ?? "none");
64
+ }
65
+ }, [background?.kind]);
58
66
  const [fileError, setFileError] = useState(null);
59
67
  const [dragging, setDragging] = useState(false);
60
68
  const [uploading, setUploading] = useState(null);
@@ -0,0 +1,50 @@
1
+ import { PluginNav } from "../plugins/pluginNav.js";
2
+ /** The id of the Settings section the booking plugin's manifest contributes (`ui.settingsSections`) - what tells the client it is running. */
3
+ export declare const BOOKING_SETTINGS_SECTION_ID = "booking-types";
4
+ /**
5
+ * Where the booking plugin's Settings pages are mounted (`/settings/booking-types`), or `undefined` when the plugin is not running.
6
+ *
7
+ * A server only puts a plugin's navigation into `pluginNav` while the plugin is enabled and its pages built, so its section being there is the
8
+ * client's one way of knowing (the plugin list itself is admin-only).
9
+ */
10
+ export declare function bookingSettingsHref(pluginNav: PluginNav | undefined): string | undefined;
11
+ /** The plugin's "new booking link" page for `mailboxUid` - the full form behind the popover's More options. */
12
+ export declare function newBookingLinkHref(settingsHref: string, mailboxUid: string): string;
13
+ export interface BookingAvailabilityWindow {
14
+ /** 0 (Sunday) to 6 (Saturday). */
15
+ dayOfWeek: number;
16
+ startMinute: number;
17
+ endMinute: number;
18
+ }
19
+ export type BookingLocationType = "phone" | "video" | "other";
20
+ export interface BookingMeetingType {
21
+ name: string;
22
+ durationMinutes: number;
23
+ locationOptions: {
24
+ type: BookingLocationType;
25
+ }[];
26
+ }
27
+ export interface CreateBookingTypeInput {
28
+ mailboxUid: string;
29
+ calendarFolderUid: string;
30
+ slug: string;
31
+ name: string;
32
+ hostDisplayName: string;
33
+ meetingTypes: BookingMeetingType[];
34
+ timezone: string;
35
+ availability: BookingAvailabilityWindow[];
36
+ bufferAfterMinutes: number;
37
+ }
38
+ /** What comes back from a create: the fields the popover needs to show the link. */
39
+ export interface CreatedBookingType {
40
+ uid: string;
41
+ mailboxUid: string;
42
+ slug: string;
43
+ name: string;
44
+ }
45
+ /** Creates a booking type with the plugin's own defaults for everything the popover leaves out (an hour's notice, 30 days ahead, no approval). */
46
+ export declare function createBookingType(input: CreateBookingTypeInput): Promise<CreatedBookingType>;
47
+ /** A URL-safe slug for a schedule's name, the way the plugin's own `normalizeSlug()` reads it; `"appointments"` when nothing usable is left. */
48
+ export declare function slugFor(name: string): string;
49
+ /** The public page of a booking type on this site's own origin (`/book/<mailbox>/<slug>`, the `@` of a mailbox uid left as it is). Browser only. */
50
+ export declare function bookingPublicUrl(mailboxUid: string, slug: string): string;
@@ -0,0 +1,57 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The calendar's side of `@rapidmx/booking-plugin`: whether the plugin is running, and the one call the quick-create popover's
7
+ * "Appointment schedule" tab makes on it - creating a booking type (`POST /api/mail/booking-types`).
8
+ *
9
+ * The plugin ships its own typed client, but inside its own UI (`apps/shared/bookingApi.ts` of the plugin), which this package cannot import,
10
+ * so the few fields this popover writes are mirrored here. The plugin's `BaseBookingTypeRoute` is the authority: it normalizes the slug,
11
+ * assigns the uids, refuses a taken slug with a 409 and validates the availability, durations and calendar folder.
12
+ */
13
+ import { apiFetch } from "@rapidmx/react-shared/util/api.js";
14
+ import { isSafePluginHref } from "../plugins/pluginNav.js";
15
+ /** The id of the Settings section the booking plugin's manifest contributes (`ui.settingsSections`) - what tells the client it is running. */
16
+ export const BOOKING_SETTINGS_SECTION_ID = "booking-types";
17
+ /**
18
+ * Where the booking plugin's Settings pages are mounted (`/settings/booking-types`), or `undefined` when the plugin is not running.
19
+ *
20
+ * A server only puts a plugin's navigation into `pluginNav` while the plugin is enabled and its pages built, so its section being there is the
21
+ * client's one way of knowing (the plugin list itself is admin-only).
22
+ */
23
+ export function bookingSettingsHref(pluginNav) {
24
+ const section = pluginNav?.settingsSections?.find((item) => item.id === BOOKING_SETTINGS_SECTION_ID);
25
+ return section && isSafePluginHref(section.href) ? section.href.replace(/\/+$/, "") : undefined;
26
+ }
27
+ /** The plugin's "new booking link" page for `mailboxUid` - the full form behind the popover's More options. */
28
+ export function newBookingLinkHref(settingsHref, mailboxUid) {
29
+ return `${settingsHref}/new?mailboxUid=${encodeURIComponent(mailboxUid)}`;
30
+ }
31
+ /** Creates a booking type with the plugin's own defaults for everything the popover leaves out (an hour's notice, 30 days ahead, no approval). */
32
+ export function createBookingType(input) {
33
+ return apiFetch("/mail/booking-types", {
34
+ method: "POST",
35
+ body: JSON.stringify({
36
+ dateOverrides: [],
37
+ bufferBeforeMinutes: 0,
38
+ minimumNoticeMinutes: 60,
39
+ bookingWindowDays: 30,
40
+ requiresApproval: false,
41
+ enabled: true,
42
+ ...input,
43
+ }),
44
+ });
45
+ }
46
+ /** A URL-safe slug for a schedule's name, the way the plugin's own `normalizeSlug()` reads it; `"appointments"` when nothing usable is left. */
47
+ export function slugFor(name) {
48
+ return (name
49
+ .trim()
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, "-")
52
+ .replace(/^-+|-+$/g, "") || "appointments");
53
+ }
54
+ /** The public page of a booking type on this site's own origin (`/book/<mailbox>/<slug>`, the `@` of a mailbox uid left as it is). Browser only. */
55
+ export function bookingPublicUrl(mailboxUid, slug) {
56
+ return `${window.location.origin}/book/${encodeURIComponent(mailboxUid).replace(/%40/g, "@")}/${encodeURIComponent(slug)}`;
57
+ }
@@ -0,0 +1,11 @@
1
+ /** Pure helper behind the calendar page's live updates: which push events say "an event changed, and the payload is only a busy block". */
2
+ import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
3
+ /**
4
+ * The uid of the event a push event announces as a redacted busy block, or `undefined` for anything else the shared push connection delivers.
5
+ *
6
+ * When somebody creates or changes a private or confidential event, restapi publishes it on the calendar's channel once, for every subscriber - and that one
7
+ * payload has to be safe for a read-only reader of a shared calendar, so it is the busy block (`redacted: true`, title "Busy", no details). It must not be
8
+ * stored as the event by the calendar's owner (or anybody else who may see it in full): they fetch the event again by its uid, which answers with as
9
+ * much as they may see.
10
+ */
11
+ export declare function redactedEventUidOf(event: PushEvent): string | undefined;
@@ -0,0 +1,23 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Pure helper behind the calendar page's live updates: which push events say "an event changed, and the payload is only a busy block". */
6
+ /** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
7
+ * which has its own action. */
8
+ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
9
+ /**
10
+ * The uid of the event a push event announces as a redacted busy block, or `undefined` for anything else the shared push connection delivers.
11
+ *
12
+ * When somebody creates or changes a private or confidential event, restapi publishes it on the calendar's channel once, for every subscriber - and that one
13
+ * payload has to be safe for a read-only reader of a shared calendar, so it is the busy block (`redacted: true`, title "Busy", no details). It must not be
14
+ * stored as the event by the calendar's owner (or anybody else who may see it in full): they fetch the event again by its uid, which answers with as
15
+ * much as they may see.
16
+ */
17
+ export function redactedEventUidOf(event) {
18
+ if (!EVENT_MODEL.test(event.type) || (event.action !== "create" && event.action !== "update")) {
19
+ return undefined;
20
+ }
21
+ const data = event.data;
22
+ return data?.redacted === true && typeof data.uid === "string" ? data.uid : undefined;
23
+ }
@@ -1,6 +1,6 @@
1
1
  import "../../../styles/app.css";
2
2
  import React, { PropsWithChildren } from "react";
3
- import { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
3
+ import type { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
4
4
  import { PluginNav, PluginNavProps } from "../../../plugins/pluginNav.js";
5
5
  export type AdminSection = "mailboxes" | "quarantine" | "ingestQueue" | "domains" | "auditLog" | "distributionLists" | "transportRules" | "escrowScopes" | "retentionPolicy" | "encryptionPolicy" | "mailboxPolicy" | "setup" | "dataRequests" | "plugins" | "branding" | "signingCertificates";
6
6
  /** A core `AdminSection`, or a plugin's `adminNav` item id (see `PluginNav`). */
@@ -5,14 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import "../../../styles/app.css";
7
7
  import { useEffect, useState } from "react";
8
- import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, } from "react-icons/hi2";
8
+ import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, } from "react-icons/hi2";
9
9
  import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
10
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
11
11
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
12
12
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
13
13
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
14
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
15
- import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
15
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
16
16
  import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
17
17
  import RailIcon from "../../layout/RailIcon.js";
18
18
  import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
@@ -110,6 +110,7 @@ export function adminNavItems(pluginNav) {
110
110
  export default function AdminShell({ active, userUid, authServerUrl, pluginNav, children }) {
111
111
  const [status, setStatus] = useState("checking");
112
112
  const [error, setError] = useState(null);
113
+ const [menuOpen, setMenuOpen] = useState(false);
113
114
  // `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
114
115
  // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
115
116
  const { branding, iconSrc } = useBranding();
@@ -198,7 +199,10 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
198
199
  id === active
199
200
  ? "bg-primary/10 text-primary-dark"
200
201
  : "text-text-muted hover:bg-surface-alt hover:text-text",
201
- ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: navItems, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "rr-solid sticky top-0 z-30 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: activeItem?.label }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut })] }), _jsx("div", { id: "app-content", className: "flex-1 pb-14 md:pb-0", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
202
+ ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(Drawer, { open: menuOpen, onClose: () => setMenuOpen(false), title: "Admin", children: _jsx("nav", { "aria-label": "Admin menu", className: "flex flex-col gap-1 -mx-2", children: navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-current": id === active ? "page" : undefined, onClick: () => setMenuOpen(false), className: [
203
+ "flex items-center gap-3 px-3 py-2.5 rounded-sm text-sm font-medium",
204
+ id === active ? "bg-primary/10 text-primary-dark" : "text-text hover:bg-surface-alt",
205
+ ].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), label] }, id))) }) }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-4 md:px-6", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("button", { type: "button", className: "md:hidden shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open menu", onClick: () => setMenuOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide truncate", children: activeItem?.label })] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut })] }), _jsx("div", { id: "app-content", className: "flex-1", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
202
206
  }
203
207
  // The user's own colours and background (`AppearanceProvider`), from what the last webmail page cached in this browser: the console never asks the server.
204
208
  return (_jsxs(AppearanceProvider, { userUid: userUid, lookUp: false, children: [content, _jsx(FrameBrandingFooter, { parsed: footer, appTitle: [...adminNavItems(pluginNav), ...OFF_RAIL_ITEMS].find((item) => item.id === active)?.label })] }));
@@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
9
9
  import { createMailbox, listMailboxDomains, resolveMailboxOwner } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import { deviceTimeZone } from "@rapidmx/react-shared/util/timeZone.js";
10
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
12
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
13
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
@@ -25,7 +26,8 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
25
26
  const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
26
27
  const [ownerMode, setOwnerMode] = useState(defaults?.ownerUserUid ? "owned" : "shared");
27
28
  const [resolvedOwner, setResolvedOwner] = useState(null);
28
- const [timezone, setTimezone] = useState("UTC");
29
+ // The admin's own device zone, not "UTC": the server's "UTC" is only a placeholder for a mailbox nobody chose a zone for.
30
+ const [timezone, setTimezone] = useState(deviceTimeZone);
29
31
  const [quotaGb, setQuotaGb] = useState(5);
30
32
  // Only replaced by the mailbox policy's default if the admin hasn't typed a quota of their own yet.
31
33
  const quotaTouched = useRef(false);