@rapidmx/web-client 0.2.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 (168) hide show
  1. package/LICENSE +382 -0
  2. package/apps/admin/_404.tsx +9 -0
  3. package/apps/admin/_500.tsx +9 -0
  4. package/apps/admin/_layout.tsx +41 -0
  5. package/apps/admin/audit-log/index.tsx +187 -0
  6. package/apps/admin/branding/index.tsx +404 -0
  7. package/apps/admin/distribution-lists/[uid].tsx +70 -0
  8. package/apps/admin/distribution-lists/index.tsx +115 -0
  9. package/apps/admin/distribution-lists/new/index.tsx +114 -0
  10. package/apps/admin/domains/[uid].tsx +259 -0
  11. package/apps/admin/domains/index.tsx +125 -0
  12. package/apps/admin/domains/new/index.tsx +84 -0
  13. package/apps/admin/index.tsx +82 -0
  14. package/apps/admin/ingest-queue/index.tsx +116 -0
  15. package/apps/admin/mailboxes/[uid].tsx +126 -0
  16. package/apps/admin/mailboxes/new/index.tsx +291 -0
  17. package/apps/admin/quarantine/index.tsx +171 -0
  18. package/apps/admin/transport-rules/[uid].tsx +143 -0
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
  20. package/apps/admin/transport-rules/index.tsx +117 -0
  21. package/apps/admin/transport-rules/new/index.tsx +105 -0
  22. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
  23. package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
  24. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
  25. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
  26. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
  27. package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
  29. package/apps/shared/components/calendar/EventModal.tsx +513 -0
  30. package/apps/shared/components/calendar/MonthView.tsx +139 -0
  31. package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
  32. package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
  33. package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
  34. package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
  35. package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
  36. package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
  37. package/apps/shared/components/contacts/ContactForm.tsx +314 -0
  38. package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
  40. package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
  41. package/apps/shared/components/layout/AppShell.tsx +165 -0
  42. package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
  44. package/apps/shared/components/layout/UserMenu.tsx +150 -0
  45. package/apps/shared/components/mail/ConversationList.tsx +61 -0
  46. package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
  49. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
  50. package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
  51. package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
  52. package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
  53. package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
  54. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
  55. package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
  56. package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
  57. package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
  58. package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
  59. package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
  60. package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
  61. package/apps/shared/styles/app.css +265 -0
  62. package/apps/www/_404.tsx +9 -0
  63. package/apps/www/_500.tsx +9 -0
  64. package/apps/www/_layout.tsx +45 -0
  65. package/apps/www/calendar/index.tsx +407 -0
  66. package/apps/www/contacts/[uid].tsx +88 -0
  67. package/apps/www/contacts/index.tsx +436 -0
  68. package/apps/www/index.tsx +375 -0
  69. package/apps/www/messages/[uid].tsx +70 -0
  70. package/apps/www/settings/auto-reply/index.tsx +129 -0
  71. package/apps/www/settings/booking-types/[uid].tsx +300 -0
  72. package/apps/www/settings/booking-types/index.tsx +98 -0
  73. package/apps/www/settings/booking-types/new/index.tsx +212 -0
  74. package/apps/www/settings/filters/[uid].tsx +155 -0
  75. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
  76. package/apps/www/settings/filters/index.tsx +99 -0
  77. package/apps/www/settings/filters/new/index.tsx +126 -0
  78. package/apps/www/settings/focused-inbox/index.tsx +148 -0
  79. package/apps/www/settings/read-receipts/index.tsx +125 -0
  80. package/apps/www/settings/signatures/[uid].tsx +167 -0
  81. package/apps/www/settings/signatures/index.tsx +85 -0
  82. package/apps/www/settings/signatures/new/index.tsx +129 -0
  83. package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
  84. package/apps/www/tasks/index.tsx +530 -0
  85. package/dist/apps/admin/_404.js +4 -0
  86. package/dist/apps/admin/_500.js +4 -0
  87. package/dist/apps/admin/_layout.js +10 -0
  88. package/dist/apps/admin/audit-log/index.js +66 -0
  89. package/dist/apps/admin/branding/index.js +126 -0
  90. package/dist/apps/admin/distribution-lists/[uid].js +34 -0
  91. package/dist/apps/admin/distribution-lists/index.js +31 -0
  92. package/dist/apps/admin/distribution-lists/new/index.js +51 -0
  93. package/dist/apps/admin/domains/[uid].js +102 -0
  94. package/dist/apps/admin/domains/index.js +31 -0
  95. package/dist/apps/admin/domains/new/index.js +41 -0
  96. package/dist/apps/admin/index.js +32 -0
  97. package/dist/apps/admin/ingest-queue/index.js +48 -0
  98. package/dist/apps/admin/mailboxes/[uid].js +59 -0
  99. package/dist/apps/admin/mailboxes/new/index.js +93 -0
  100. package/dist/apps/admin/quarantine/index.js +66 -0
  101. package/dist/apps/admin/transport-rules/[uid].js +87 -0
  102. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
  103. package/dist/apps/admin/transport-rules/index.js +32 -0
  104. package/dist/apps/admin/transport-rules/new/index.js +50 -0
  105. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
  106. package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
  107. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
  108. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
  109. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
  110. package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
  111. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
  112. package/dist/apps/shared/components/calendar/EventModal.js +167 -0
  113. package/dist/apps/shared/components/calendar/MonthView.js +38 -0
  114. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
  115. package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
  116. package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
  117. package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
  118. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
  119. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
  120. package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
  121. package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
  122. package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
  123. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
  124. package/dist/apps/shared/components/layout/AppShell.js +68 -0
  125. package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
  126. package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
  127. package/dist/apps/shared/components/layout/UserMenu.js +59 -0
  128. package/dist/apps/shared/components/mail/ConversationList.js +17 -0
  129. package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
  130. package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
  131. package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
  132. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
  133. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
  134. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
  135. package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
  136. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
  137. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
  138. package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
  139. package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
  140. package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
  141. package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
  142. package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
  143. package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
  144. package/dist/apps/shared/styles/app.css +265 -0
  145. package/dist/apps/www/_404.js +4 -0
  146. package/dist/apps/www/_500.js +4 -0
  147. package/dist/apps/www/_layout.js +8 -0
  148. package/dist/apps/www/calendar/index.js +228 -0
  149. package/dist/apps/www/contacts/[uid].js +62 -0
  150. package/dist/apps/www/contacts/index.js +262 -0
  151. package/dist/apps/www/index.js +228 -0
  152. package/dist/apps/www/messages/[uid].js +47 -0
  153. package/dist/apps/www/settings/auto-reply/index.js +54 -0
  154. package/dist/apps/www/settings/booking-types/[uid].js +129 -0
  155. package/dist/apps/www/settings/booking-types/index.js +29 -0
  156. package/dist/apps/www/settings/booking-types/new/index.js +86 -0
  157. package/dist/apps/www/settings/filters/[uid].js +94 -0
  158. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
  159. package/dist/apps/www/settings/filters/index.js +30 -0
  160. package/dist/apps/www/settings/filters/new/index.js +61 -0
  161. package/dist/apps/www/settings/focused-inbox/index.js +69 -0
  162. package/dist/apps/www/settings/read-receipts/index.js +51 -0
  163. package/dist/apps/www/settings/signatures/[uid].js +96 -0
  164. package/dist/apps/www/settings/signatures/index.js +34 -0
  165. package/dist/apps/www/settings/signatures/new/index.js +63 -0
  166. package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
  167. package/dist/apps/www/tasks/index.js +280 -0
  168. package/package.json +87 -0
@@ -0,0 +1,276 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode, useState } from "react";
6
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
7
+
8
+ const INPUT_CLASS =
9
+ "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
10
+
11
+ /** One condition field this scope's `Conditions` shape supports — `"list"` renders an add/remove chip
12
+ * editor for a `string[]` field, `"boolean"` a plain checkbox, `"select"` a fixed-choice dropdown for a
13
+ * field with a small closed set of string values (e.g. `MailFilterConditions.importance`). */
14
+ export interface ConditionFieldDef {
15
+ key: string;
16
+ label: string;
17
+ kind: "list" | "boolean" | "select";
18
+ placeholder?: string;
19
+ /** Required when `kind` is `"select"` — the fixed set of values this field may hold, plus an
20
+ * implicit "Any" option that clears the field entirely. */
21
+ options?: { value: string; label: string }[];
22
+ }
23
+
24
+ /** One action type this scope's `Action` union supports — the per-scope registry the shared condition
25
+ * editor/chrome below is deliberately kept out of (transport-rule and mail-filter actions are
26
+ * different enough vocabularies that one component special-casing both would be worse than this). */
27
+ export interface ActionTypeDef<A> {
28
+ value: string;
29
+ label: string;
30
+ /** Builds a fresh default action of this type when picked from "Add action". */
31
+ createDefault: () => A;
32
+ /** Renders this action's own type-specific fields (e.g. header name/value). `null` for an action
33
+ * type with nothing further to configure. */
34
+ render: (action: A, onChange: (next: A) => void) => ReactNode;
35
+ }
36
+
37
+ export interface RuleBuilderValue<C extends object, A extends { type: string }> {
38
+ enabled: boolean;
39
+ sequence: number;
40
+ stopProcessingRules: boolean;
41
+ conditions: C;
42
+ actions: A[];
43
+ }
44
+
45
+ export interface RuleBuilderProps<C extends object, A extends { type: string }> {
46
+ value: RuleBuilderValue<C, A>;
47
+ onChange: (next: RuleBuilderValue<C, A>) => void;
48
+ conditionFields: ConditionFieldDef[];
49
+ actionTypes: ActionTypeDef<A>[];
50
+ }
51
+
52
+ /**
53
+ * Shared condition-row editor and enabled/sequence/stop-processing chrome for a rule that matches
54
+ * conditions and runs actions in order — `TransportRule` (admin, org-wide) and `MailFilterRule`
55
+ * (mailbox-scoped) both have this same `{enabled, sequence, stopProcessingRules, conditions, actions}`
56
+ * shape. Conditions are driven by a declarative `conditionFields` list so both scopes share this one
57
+ * editor despite their `Conditions` types not being identical — `MailFilterConditions.importance`
58
+ * (`MailFilterRule`'s one condition field `TransportRuleConditions` has no equivalent of) is what forced
59
+ * this list beyond `"list"`/`"boolean"` to add a third `"select"` kind for a small fixed-choice field,
60
+ * confirming the shared editor still generalizes rather than needing a fork; actions are driven by a
61
+ * small per-scope `actionTypes` registry instead, since the two action vocabularies (reject/quarantine/
62
+ * add-header/add-recipient vs. move/copy/delete/mark-read/forward) are different enough that one
63
+ * component special-casing both would be worse than this split.
64
+ */
65
+ export default function RuleBuilder<C extends object, A extends { type: string }>({
66
+ value,
67
+ onChange,
68
+ conditionFields,
69
+ actionTypes,
70
+ }: RuleBuilderProps<C, A>) {
71
+ const [draftText, setDraftText] = useState<Record<string, string>>({});
72
+ const [newActionType, setNewActionType] = useState(actionTypes[0]?.value ?? "");
73
+ const conditions = value.conditions as Record<string, unknown>;
74
+
75
+ function patch(next: Partial<RuleBuilderValue<C, A>>) {
76
+ onChange({ ...value, ...next });
77
+ }
78
+
79
+ function addListEntry(key: string) {
80
+ const text = (draftText[key] ?? "").trim();
81
+ if (!text) return;
82
+ const existing = (conditions[key] as string[] | undefined) ?? [];
83
+ if (existing.includes(text)) return;
84
+ patch({ conditions: { ...conditions, [key]: [...existing, text] } as C });
85
+ setDraftText({ ...draftText, [key]: "" });
86
+ }
87
+
88
+ function removeListEntry(key: string, entry: string) {
89
+ const existing = (conditions[key] as string[] | undefined) ?? [];
90
+ patch({ conditions: { ...conditions, [key]: existing.filter((e) => e !== entry) } as C });
91
+ }
92
+
93
+ function toggleBoolean(key: string) {
94
+ patch({ conditions: { ...conditions, [key]: !conditions[key] } as C });
95
+ }
96
+
97
+ function setSelect(key: string, value: string) {
98
+ patch({ conditions: { ...conditions, [key]: value || undefined } as C });
99
+ }
100
+
101
+ function addAction() {
102
+ const def = actionTypes.find((t) => t.value === newActionType);
103
+ if (!def) return;
104
+ patch({ actions: [...value.actions, def.createDefault()] });
105
+ }
106
+
107
+ function updateAction(index: number, next: A) {
108
+ patch({ actions: value.actions.map((a, i) => (i === index ? next : a)) });
109
+ }
110
+
111
+ function removeAction(index: number) {
112
+ patch({ actions: value.actions.filter((_, i) => i !== index) });
113
+ }
114
+
115
+ return (
116
+ <div className="flex flex-col gap-5">
117
+ <div className="bg-surface border border-border rounded-md p-6">
118
+ <h2 className="text-base font-bold uppercase tracking-wide mb-4">Conditions</h2>
119
+ <div className="flex flex-col gap-4">
120
+ {conditionFields.map((field) => {
121
+ if (field.kind === "boolean") {
122
+ return (
123
+ <label key={field.key} className="flex items-center gap-2 text-sm">
124
+ <input
125
+ type="checkbox"
126
+ checked={!!conditions[field.key]}
127
+ onChange={() => toggleBoolean(field.key)}
128
+ />
129
+ {field.label}
130
+ </label>
131
+ );
132
+ }
133
+ if (field.kind === "select") {
134
+ return (
135
+ <div key={field.key}>
136
+ <label className="text-sm font-semibold mb-1.5 block" htmlFor={`condition-${field.key}`}>
137
+ {field.label}
138
+ </label>
139
+ <select
140
+ id={`condition-${field.key}`}
141
+ className={INPUT_CLASS}
142
+ value={(conditions[field.key] as string | undefined) ?? ""}
143
+ onChange={(e) => setSelect(field.key, e.target.value)}
144
+ >
145
+ <option value="">Any</option>
146
+ {(field.options ?? []).map((opt) => (
147
+ <option key={opt.value} value={opt.value}>
148
+ {opt.label}
149
+ </option>
150
+ ))}
151
+ </select>
152
+ </div>
153
+ );
154
+ }
155
+ return (
156
+ <div key={field.key}>
157
+ <div className="text-sm font-semibold mb-1.5">{field.label}</div>
158
+ <div className="flex flex-wrap gap-2 mb-2">
159
+ {((conditions[field.key] as string[] | undefined) ?? []).map((entry) => (
160
+ <span
161
+ key={entry}
162
+ className="inline-flex items-center gap-1.5 text-xs py-1 px-2.5 rounded-pill bg-surface-alt"
163
+ >
164
+ {entry}
165
+ <button
166
+ type="button"
167
+ aria-label={`Remove ${entry}`}
168
+ onClick={() => removeListEntry(field.key, entry)}
169
+ className="text-text-muted hover:text-danger"
170
+ >
171
+ &times;
172
+ </button>
173
+ </span>
174
+ ))}
175
+ </div>
176
+ <div className="flex gap-2">
177
+ <input
178
+ type="text"
179
+ aria-label={field.label}
180
+ className={INPUT_CLASS}
181
+ placeholder={field.placeholder ?? "Add a value"}
182
+ value={draftText[field.key] ?? ""}
183
+ onChange={(e) => setDraftText({ ...draftText, [field.key]: e.target.value })}
184
+ onKeyDown={(e) => {
185
+ if (e.key === "Enter") {
186
+ e.preventDefault();
187
+ addListEntry(field.key);
188
+ }
189
+ }}
190
+ />
191
+ <Button
192
+ type="button"
193
+ variant="secondary"
194
+ className="!w-auto"
195
+ onClick={() => addListEntry(field.key)}
196
+ >
197
+ Add
198
+ </Button>
199
+ </div>
200
+ </div>
201
+ );
202
+ })}
203
+ </div>
204
+ </div>
205
+
206
+ <div className="bg-surface border border-border rounded-md p-6">
207
+ <h2 className="text-base font-bold uppercase tracking-wide mb-4">Actions</h2>
208
+ <div className="flex flex-col gap-3 mb-4">
209
+ {value.actions.length === 0 && <p className="text-sm text-text-muted">No actions yet.</p>}
210
+ {value.actions.map((action, index) => {
211
+ const def = actionTypes.find((t) => t.value === action.type);
212
+ return (
213
+ <div key={index} className="border border-border rounded-sm p-3">
214
+ <div className="flex items-center justify-between mb-2">
215
+ <span className="text-sm font-semibold">{def?.label ?? action.type}</span>
216
+ <button
217
+ type="button"
218
+ onClick={() => removeAction(index)}
219
+ className="text-sm text-danger hover:underline"
220
+ >
221
+ Remove
222
+ </button>
223
+ </div>
224
+ {def?.render(action, (next) => updateAction(index, next))}
225
+ </div>
226
+ );
227
+ })}
228
+ </div>
229
+ <div className="flex gap-2">
230
+ <select
231
+ aria-label="New action type"
232
+ className={INPUT_CLASS}
233
+ value={newActionType}
234
+ onChange={(e) => setNewActionType(e.target.value)}
235
+ >
236
+ {actionTypes.map((t) => (
237
+ <option key={t.value} value={t.value}>
238
+ {t.label}
239
+ </option>
240
+ ))}
241
+ </select>
242
+ <Button type="button" variant="secondary" className="!w-auto" onClick={addAction}>
243
+ Add action
244
+ </Button>
245
+ </div>
246
+ </div>
247
+
248
+ <div className="bg-surface border border-border rounded-md p-6">
249
+ <h2 className="text-base font-bold uppercase tracking-wide mb-4">Settings</h2>
250
+ <div className="flex flex-col gap-3">
251
+ <label className="flex items-center gap-2 text-sm">
252
+ <input type="checkbox" checked={value.enabled} onChange={(e) => patch({ enabled: e.target.checked })} />
253
+ Enabled
254
+ </label>
255
+ <label className="flex items-center gap-2 text-sm">
256
+ <input
257
+ type="checkbox"
258
+ checked={value.stopProcessingRules}
259
+ onChange={(e) => patch({ stopProcessingRules: e.target.checked })}
260
+ />
261
+ Stop processing more rules once this one matches
262
+ </label>
263
+ <label className="flex items-center gap-2 text-sm">
264
+ Sequence (evaluation order, ascending)
265
+ <input
266
+ type="number"
267
+ className="w-20 text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
268
+ value={value.sequence}
269
+ onChange={(e) => patch({ sequence: Number(e.target.value) })}
270
+ />
271
+ </label>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ );
276
+ }
@@ -0,0 +1,223 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
+ import { HiOutlineBars3 } from "react-icons/hi2";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
9
+ import { Mailbox, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
+ import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
+ import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
+
15
+ export interface SettingsSectionDef {
16
+ id: string;
17
+ href: string;
18
+ label: string;
19
+ }
20
+
21
+ /** Every settings section with its own sidebar entry. */
22
+ export const SETTINGS_SECTIONS: SettingsSectionDef[] = [
23
+ { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
24
+ { id: "filters", href: "/settings/filters", label: "Mail Filters" },
25
+ { id: "signatures", href: "/settings/signatures", label: "Signatures" },
26
+ { id: "focused-inbox", href: "/settings/focused-inbox", label: "Focused Inbox" },
27
+ { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
28
+ { id: "booking-types", href: "/settings/booking-types", label: "Booking Links" },
29
+ ];
30
+
31
+ export type SettingsSectionId = (typeof SETTINGS_SECTIONS)[number]["id"];
32
+
33
+ export interface SettingsShellProps extends Omit<AppShellProps, "active"> {
34
+ /** Which entry in `SETTINGS_SECTIONS` is highlighted in this shell's own sidebar — distinct from
35
+ * `AppShell`'s own `active` (always hardcoded to `"settings"` below), since Settings isn't a rail
36
+ * icon (see `AppShell.tsx`'s `AppShellApp` doc comment) but still needs its own section
37
+ * highlighting. Each settings page passes its own section id explicitly, the same "gotcha" this
38
+ * plan already calls out for every shell's `active` prop. */
39
+ active: SettingsSectionId;
40
+ }
41
+
42
+ export interface SettingsShellContextValue {
43
+ /** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox) —
44
+ * every settings section so far (Automatic Replies) edits mailbox-scoped fields. */
45
+ mailboxUid?: string;
46
+ mailboxes: Mailbox[];
47
+ }
48
+
49
+ const SettingsShellContext = createContext<SettingsShellContextValue>({ mailboxes: [] });
50
+
51
+ /** Reads the mailbox Settings is currently scoped to, as resolved by the enclosing `SettingsShell`. */
52
+ export function useSettingsShell(): SettingsShellContextValue {
53
+ return useContext(SettingsShellContext);
54
+ }
55
+
56
+ type Status = "checking" | "error" | "ready";
57
+
58
+ /**
59
+ * The Settings area's shell — reached via a `UserMenu` entry rather than a 5th `AppShell` icon (per
60
+ * this plan's own confirmed decision), so it always passes `active="settings"` to `AppShell` regardless
61
+ * of which section is showing. Unlike `ContactsShell`/`TasksShell` (whose sidebar is just a mailbox
62
+ * switcher, since each has exactly one well-known folder), this shell's sidebar is a real list of
63
+ * settings sections (`SETTINGS_SECTIONS`) — full-page navigation between them, same as the top-level
64
+ * app rail, since this framework has no client-side router.
65
+ */
66
+ export default function SettingsShell({
67
+ active,
68
+ userUid,
69
+ authServerUrl,
70
+ impersonating,
71
+ impersonationBaseUrl,
72
+ trusted,
73
+ children,
74
+ }: PropsWithChildren<SettingsShellProps>) {
75
+ const [status, setStatus] = useState<Status>("checking");
76
+ const [error, setError] = useState<string | null>(null);
77
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
78
+ const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
79
+ const [drawerOpen, setDrawerOpen] = useState(false);
80
+
81
+ useEffect(() => {
82
+ setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
83
+ }, []);
84
+
85
+ useEffect(() => {
86
+ if (!userUid) {
87
+ return;
88
+ }
89
+ listMailboxes({ limit: 100 })
90
+ .then((result) => {
91
+ setMailboxes(result);
92
+ setStatus("ready");
93
+ })
94
+ .catch((err) => {
95
+ setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
96
+ setStatus("error");
97
+ });
98
+ }, [userUid]);
99
+
100
+ const mailboxUid: string | undefined =
101
+ (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
102
+ mailboxes[0]?.uid;
103
+
104
+ const contextValue = useMemo<SettingsShellContextValue>(() => ({ mailboxUid, mailboxes }), [mailboxUid, mailboxes]);
105
+
106
+ // A full-screen takeover, not nested inside the rest of the app's chrome — same as ContactsShell/
107
+ // TasksShell, there's nothing else for a mailbox-less caller to configure here yet.
108
+ if (userUid && status === "ready" && !mailboxUid) {
109
+ return <MailboxProvisioning />;
110
+ }
111
+
112
+ let inner: ReactNode = null;
113
+ if (userUid && status === "checking") {
114
+ inner = (
115
+ <div className="w-56 shrink-0 border-r border-border flex flex-col gap-4 p-3">
116
+ <Skeleton height="h-9" />
117
+ <SkeletonList count={3} />
118
+ </div>
119
+ );
120
+ } else if (userUid && status === "error") {
121
+ inner = (
122
+ <div className="flex-1 flex items-center justify-center p-8">
123
+ <div className="w-full max-w-md">
124
+ <Alert>{error}</Alert>
125
+ </div>
126
+ </div>
127
+ );
128
+ } else if (userUid && status === "ready") {
129
+ // A function, not a plain JSX constant — same reasoning as ContactsShell's identical comment:
130
+ // rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the
131
+ // `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
132
+ const sidebarContent = (idPrefix: string) => (
133
+ <>
134
+ {mailboxes.length > 1 && (
135
+ <div>
136
+ <label
137
+ className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
138
+ htmlFor={`${idPrefix}-settings-mailbox-switcher`}
139
+ >
140
+ Mailbox
141
+ </label>
142
+ <select
143
+ id={`${idPrefix}-settings-mailbox-switcher`}
144
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
145
+ value={mailboxUid}
146
+ onChange={(e) => {
147
+ // `active` always names a real entry in `SETTINGS_SECTIONS` — every settings
148
+ // page passes its own section id explicitly (see `SettingsShellProps.active`'s
149
+ // own doc comment), so this lookup can't miss.
150
+ const section = SETTINGS_SECTIONS.find((s) => s.id === active)!;
151
+ window.location.href = `${section.href}?mailboxUid=${encodeURIComponent(e.target.value)}`;
152
+ }}
153
+ >
154
+ {mailboxes.map((mb) => (
155
+ <option key={mb.uid} value={mb.uid}>
156
+ {mb.displayName}
157
+ {mb.ownerUserUid ? "" : " (shared)"}
158
+ </option>
159
+ ))}
160
+ </select>
161
+ </div>
162
+ )}
163
+ <nav aria-label="Settings sections" className="flex flex-col gap-0.5">
164
+ {SETTINGS_SECTIONS.map((section) => (
165
+ <a
166
+ key={section.id}
167
+ // `inner` (this whole branch) is only ever computed once `status === "ready"`
168
+ // and `mailboxUid` has already resolved truthy — a falsy `mailboxUid` returns
169
+ // `<MailboxProvisioning />` above instead (and TypeScript's own narrowing of
170
+ // this `const` already reflects that, no `!` needed) — so no fallback is
171
+ // needed here, matching this plan's established "dead guard the UI
172
+ // structurally can't trigger" removal precedent (e.g.
173
+ // `DomainDetailContent.handleVerify`).
174
+ href={`${section.href}?mailboxUid=${encodeURIComponent(mailboxUid)}`}
175
+ aria-current={section.id === active ? "page" : undefined}
176
+ className={[
177
+ "block px-2.5 py-1.5 rounded-sm text-sm",
178
+ section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
179
+ ].join(" ")}
180
+ >
181
+ {section.label}
182
+ </a>
183
+ ))}
184
+ </nav>
185
+ </>
186
+ );
187
+
188
+ inner = (
189
+ <>
190
+ <aside className="hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3">
191
+ {sidebarContent("desktop")}
192
+ </aside>
193
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Settings">
194
+ <div className="flex flex-col gap-3">{sidebarContent("mobile")}</div>
195
+ </Drawer>
196
+ <main className="flex-1 min-w-0 flex flex-col">
197
+ <button
198
+ type="button"
199
+ className="md:hidden m-3 self-start w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
200
+ aria-label="Open settings menu"
201
+ onClick={() => setDrawerOpen(true)}
202
+ >
203
+ <HiOutlineBars3 size={20} aria-hidden="true" />
204
+ </button>
205
+ <SettingsShellContext.Provider value={contextValue}>{children}</SettingsShellContext.Provider>
206
+ </main>
207
+ </>
208
+ );
209
+ }
210
+
211
+ return (
212
+ <AppShell
213
+ active="settings"
214
+ userUid={userUid}
215
+ authServerUrl={authServerUrl}
216
+ impersonating={impersonating}
217
+ impersonationBaseUrl={impersonationBaseUrl}
218
+ trusted={trusted}
219
+ >
220
+ {inner}
221
+ </AppShell>
222
+ );
223
+ }