@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,530 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useMemo, useState } from "react";
6
+ import { endOfWeek, isAfter, isBefore, isToday, startOfDay } from "date-fns";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import {
9
+ Task,
10
+ TaskPriority,
11
+ createTask,
12
+ deleteTask,
13
+ listTasks,
14
+ setTaskCompleted,
15
+ setTaskMyDay,
16
+ updateTask,
17
+ } from "@rapidmx/react-shared/tasks/tasksApi.js";
18
+ import { listFlaggedMessages } from "@rapidmx/react-shared/mail/flaggedMessages.js";
19
+ import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
20
+ import TasksShell, { TasksShellProps, useTasksShell } from "../../shared/components/tasks/layout/TasksShell.js";
21
+ import TasksSidebar, { TasksView } from "../../shared/components/tasks/TasksSidebar.js";
22
+ import TasksToolbar, { TasksViewMode } from "../../shared/components/tasks/TasksToolbar.js";
23
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
24
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
25
+
26
+ const INPUT_CLASS =
27
+ "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
28
+
29
+ export default function TasksPage(props: TasksShellProps) {
30
+ return (
31
+ <TasksShell {...props}>
32
+ <TasksContent />
33
+ </TasksShell>
34
+ );
35
+ }
36
+
37
+ type Bucket = "Overdue" | "Today" | "This Week" | "Later" | "No due date";
38
+ const BUCKET_ORDER: Bucket[] = ["Overdue", "Today", "This Week", "Later", "No due date"];
39
+
40
+ /** Buckets a task by its due date relative to `now`, matching the Outlook/To-Do convention this list mirrors. */
41
+ function bucketFor(task: Task, now: Date): Bucket {
42
+ if (!task.dueDate) {
43
+ return "No due date";
44
+ }
45
+ const due = new Date(task.dueDate);
46
+ if (isBefore(due, startOfDay(now))) {
47
+ return "Overdue";
48
+ }
49
+ if (isToday(due)) {
50
+ return "Today";
51
+ }
52
+ if (!isAfter(due, endOfWeek(now, { weekStartsOn: 1 }))) {
53
+ return "This Week";
54
+ }
55
+ return "Later";
56
+ }
57
+
58
+ const PRIORITY_LABEL: Record<TaskPriority, string> = { low: "Low", normal: "Normal", high: "High" };
59
+ const PRIORITY_CLASS: Record<TaskPriority, string> = {
60
+ low: "text-text-muted",
61
+ normal: "text-text-muted",
62
+ high: "text-danger",
63
+ };
64
+
65
+ function TasksContent() {
66
+ const { folderUid, mailboxUid, userUid } = useTasksShell();
67
+ const [tasks, setTasks] = useState<Task[]>([]);
68
+ const [loading, setLoading] = useState(true);
69
+ const [error, setError] = useState<string | null>(null);
70
+ const [title, setTitle] = useState("");
71
+ const [dueDate, setDueDate] = useState("");
72
+ const [priority, setPriority] = useState<TaskPriority>("normal");
73
+ const [createError, setCreateError] = useState<string | null>(null);
74
+ const [creating, setCreating] = useState(false);
75
+ const [view, setView] = useState<TasksView>({ type: "all" });
76
+ const [viewMode, setViewMode] = useState<TasksViewMode>("list");
77
+ const [checkedUids, setCheckedUids] = useState<Set<string>>(new Set());
78
+ const [flaggedMessages, setFlaggedMessages] = useState<Message[]>([]);
79
+ const [flaggedLoading, setFlaggedLoading] = useState(false);
80
+ const [flaggedError, setFlaggedError] = useState<string | null>(null);
81
+
82
+ function reload(): Promise<void> {
83
+ if (!folderUid) {
84
+ setTasks([]);
85
+ setLoading(false);
86
+ return Promise.resolve();
87
+ }
88
+ setLoading(true);
89
+ setError(null);
90
+ return listTasks(folderUid, { limit: 500 })
91
+ .then(setTasks)
92
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load tasks."))
93
+ .finally(() => setLoading(false));
94
+ }
95
+
96
+ useEffect(() => {
97
+ void reload();
98
+ }, [folderUid]);
99
+
100
+ useEffect(() => {
101
+ if (view.type !== "flagged" || !mailboxUid) {
102
+ return;
103
+ }
104
+ setFlaggedLoading(true);
105
+ setFlaggedError(null);
106
+ listFlaggedMessages(mailboxUid)
107
+ .then(setFlaggedMessages)
108
+ .catch((err) => setFlaggedError(err instanceof ApiRequestError ? err.message : "Could not load flagged email."))
109
+ .finally(() => setFlaggedLoading(false));
110
+ }, [view, mailboxUid]);
111
+
112
+ async function handleCreate(e: FormEvent) {
113
+ e.preventDefault();
114
+ setCreateError(null);
115
+ if (!title.trim()) {
116
+ setCreateError("A title is required.");
117
+ return;
118
+ }
119
+ if (!mailboxUid || !folderUid) {
120
+ return;
121
+ }
122
+ setCreating(true);
123
+ try {
124
+ const created = await createTask({
125
+ mailboxUid,
126
+ folderUid,
127
+ title: title.trim(),
128
+ dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
129
+ priority,
130
+ });
131
+ setTasks((prev) => [...prev, created]);
132
+ setTitle("");
133
+ setDueDate("");
134
+ setPriority("normal");
135
+ } catch (err) {
136
+ setCreateError(err instanceof ApiRequestError ? err.message : "Could not create this task.");
137
+ } finally {
138
+ setCreating(false);
139
+ }
140
+ }
141
+
142
+ async function handleToggle(task: Task) {
143
+ setError(null);
144
+ const nextCompleted = !task.completed;
145
+ setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: nextCompleted } : t)));
146
+ try {
147
+ const updated = await setTaskCompleted(task, nextCompleted);
148
+ setTasks((prev) => prev.map((t) => (t.uid === task.uid ? updated : t)));
149
+ } catch (err) {
150
+ setTasks((prev) => prev.map((t) => (t.uid === task.uid ? task : t)));
151
+ setError(err instanceof ApiRequestError ? err.message : "Could not update this task.");
152
+ }
153
+ }
154
+
155
+ async function handleDelete(task: Task) {
156
+ setError(null);
157
+ try {
158
+ await deleteTask(task.uid, task.version);
159
+ setTasks((prev) => prev.filter((t) => t.uid !== task.uid));
160
+ } catch (err) {
161
+ setError(err instanceof ApiRequestError ? err.message : "Could not delete this task.");
162
+ }
163
+ }
164
+
165
+ function handleSelectView(next: TasksView) {
166
+ setView(next);
167
+ setCheckedUids(new Set());
168
+ }
169
+
170
+ function toggleChecked(uid: string) {
171
+ setCheckedUids((prev) => {
172
+ const next = new Set(prev);
173
+ if (next.has(uid)) {
174
+ next.delete(uid);
175
+ } else {
176
+ next.add(uid);
177
+ }
178
+ return next;
179
+ });
180
+ }
181
+
182
+ const viewFiltered = useMemo(() => {
183
+ switch (view.type) {
184
+ case "myDay":
185
+ return tasks.filter((t) => t.myDay);
186
+ case "important":
187
+ return tasks.filter((t) => t.priority === "high");
188
+ case "planned":
189
+ return tasks.filter((t) => t.dueDate != null);
190
+ case "assignedToMe":
191
+ return tasks.filter((t) => t.assignedTo === userUid);
192
+ case "list":
193
+ return tasks.filter((t) => t.taskListUid === view.uid);
194
+ case "flagged":
195
+ return [];
196
+ case "all":
197
+ return tasks;
198
+ }
199
+ }, [tasks, view, userUid]);
200
+
201
+ const checkedTasks = viewFiltered.filter((t) => checkedUids.has(t.uid));
202
+
203
+ async function handleBulkComplete() {
204
+ let bulkError: string | null = null;
205
+ for (const task of checkedTasks) {
206
+ try {
207
+ await setTaskCompleted(task, true);
208
+ } catch (err) {
209
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more tasks.";
210
+ }
211
+ }
212
+ setCheckedUids(new Set());
213
+ await reload();
214
+ setError(bulkError);
215
+ }
216
+
217
+ async function handleBulkAddToMyDay() {
218
+ let bulkError: string | null = null;
219
+ for (const task of checkedTasks) {
220
+ try {
221
+ await setTaskMyDay(task, true);
222
+ } catch (err) {
223
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more tasks.";
224
+ }
225
+ }
226
+ setCheckedUids(new Set());
227
+ await reload();
228
+ setError(bulkError);
229
+ }
230
+
231
+ async function handleBulkDelete() {
232
+ let bulkError: string | null = null;
233
+ for (const task of checkedTasks) {
234
+ try {
235
+ await deleteTask(task.uid, task.version);
236
+ } catch (err) {
237
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more tasks.";
238
+ }
239
+ }
240
+ setCheckedUids(new Set());
241
+ await reload();
242
+ setError(bulkError);
243
+ }
244
+
245
+ const grouped = useMemo(() => {
246
+ const now = new Date();
247
+ const map = new Map<Bucket, Task[]>(BUCKET_ORDER.map((b) => [b, []]));
248
+ for (const task of viewFiltered) {
249
+ if (task.completed) {
250
+ continue;
251
+ }
252
+ map.get(bucketFor(task, now))!.push(task);
253
+ }
254
+ return map;
255
+ }, [viewFiltered]);
256
+
257
+ const completedTasks = viewFiltered.filter((t) => t.completed);
258
+
259
+ return (
260
+ <div className="flex-1 flex min-h-0">
261
+ <TasksSidebar mailboxUid={mailboxUid} tasks={tasks} userUid={userUid} active={view} onSelect={handleSelectView} />
262
+ <div className="flex-1 min-w-0 flex flex-col overflow-y-auto">
263
+ <TasksToolbar
264
+ viewMode={viewMode}
265
+ onViewModeChange={setViewMode}
266
+ selectedCount={checkedTasks.length}
267
+ onComplete={handleBulkComplete}
268
+ onDelete={handleBulkDelete}
269
+ onAddToMyDay={handleBulkAddToMyDay}
270
+ />
271
+ <div role="region" aria-label="Tasks list" className="max-w-2xl mx-auto w-full flex flex-col gap-6 p-6">
272
+ <h1 className="text-xl font-bold uppercase tracking-wide">Tasks</h1>
273
+
274
+ {error && <Alert>{error}</Alert>}
275
+
276
+ {view.type === "flagged" ? (
277
+ <FlaggedEmailList loading={flaggedLoading} error={flaggedError} messages={flaggedMessages} />
278
+ ) : (
279
+ <>
280
+ <form onSubmit={handleCreate} className="flex items-center gap-2 bg-surface border border-border rounded-md p-2">
281
+ <input
282
+ type="text"
283
+ aria-label="Add a task"
284
+ className={`${INPUT_CLASS} flex-1`}
285
+ value={title}
286
+ onChange={(e) => setTitle(e.target.value)}
287
+ placeholder="Add a task"
288
+ />
289
+ <input
290
+ type="date"
291
+ aria-label="Due date"
292
+ className={INPUT_CLASS}
293
+ value={dueDate}
294
+ onChange={(e) => setDueDate(e.target.value)}
295
+ />
296
+ <select
297
+ aria-label="Priority"
298
+ className={INPUT_CLASS}
299
+ value={priority}
300
+ onChange={(e) => setPriority(e.target.value as TaskPriority)}
301
+ >
302
+ <option value="low">Low</option>
303
+ <option value="normal">Normal</option>
304
+ <option value="high">High</option>
305
+ </select>
306
+ <Button type="submit" loading={creating} disabled={creating} className="!w-auto shrink-0">
307
+ Add
308
+ </Button>
309
+ </form>
310
+ {createError && <Alert>{createError}</Alert>}
311
+
312
+ {loading ? (
313
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
314
+ ) : viewFiltered.length === 0 ? (
315
+ <p className="text-sm text-text-muted">No tasks yet.</p>
316
+ ) : viewMode === "grid" ? (
317
+ <TaskTable
318
+ tasks={viewFiltered}
319
+ checkedUids={checkedUids}
320
+ onToggleChecked={toggleChecked}
321
+ onToggle={handleToggle}
322
+ onDelete={handleDelete}
323
+ />
324
+ ) : (
325
+ <>
326
+ {BUCKET_ORDER.map((bucket) => {
327
+ const items = grouped.get(bucket)!;
328
+ if (items.length === 0) {
329
+ return null;
330
+ }
331
+ return (
332
+ <TaskGroup
333
+ key={bucket}
334
+ label={bucket}
335
+ tasks={items}
336
+ checkedUids={checkedUids}
337
+ onToggleChecked={toggleChecked}
338
+ onToggle={handleToggle}
339
+ onDelete={handleDelete}
340
+ />
341
+ );
342
+ })}
343
+ {completedTasks.length > 0 && (
344
+ <TaskGroup
345
+ label="Completed"
346
+ tasks={completedTasks}
347
+ checkedUids={checkedUids}
348
+ onToggleChecked={toggleChecked}
349
+ onToggle={handleToggle}
350
+ onDelete={handleDelete}
351
+ />
352
+ )}
353
+ </>
354
+ )}
355
+ </>
356
+ )}
357
+ </div>
358
+ </div>
359
+ </div>
360
+ );
361
+ }
362
+
363
+ function FlaggedEmailList({ loading, error, messages }: { loading: boolean; error: string | null; messages: Message[] }) {
364
+ if (error) {
365
+ return <Alert>{error}</Alert>;
366
+ }
367
+ if (loading) {
368
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
369
+ }
370
+ if (messages.length === 0) {
371
+ return <p className="text-sm text-text-muted">No flagged email.</p>;
372
+ }
373
+ return (
374
+ <ul className="bg-surface border border-border rounded-md divide-y divide-border">
375
+ {messages.map((m) => (
376
+ <li key={m.uid} className="flex items-center gap-3 py-2.5 px-3 text-sm">
377
+ <span className="flex-1 truncate">{m.subject || "(no subject)"}</span>
378
+ <span className="text-xs text-text-muted shrink-0">{m.from.address}</span>
379
+ <span className="text-xs text-text-muted shrink-0">{new Date(m.receivedDate).toLocaleDateString()}</span>
380
+ </li>
381
+ ))}
382
+ </ul>
383
+ );
384
+ }
385
+
386
+ /** A circular completion-toggle control matching Outlook's visual language (not a native checkbox), while
387
+ * staying a real accessible toggle. */
388
+ function CompletionToggle({ task, onToggle }: { task: Task; onToggle: (task: Task) => void }) {
389
+ return (
390
+ <button
391
+ type="button"
392
+ role="checkbox"
393
+ aria-checked={task.completed}
394
+ aria-label={`Mark "${task.title}" as ${task.completed ? "not complete" : "complete"}`}
395
+ onClick={() => onToggle(task)}
396
+ className={[
397
+ "w-4 h-4 rounded-full border shrink-0 flex items-center justify-center text-[10px]",
398
+ task.completed ? "bg-primary border-primary text-white" : "border-border text-transparent",
399
+ ].join(" ")}
400
+ >
401
+ &#10003;
402
+ </button>
403
+ );
404
+ }
405
+
406
+ interface TaskGroupProps {
407
+ label: string;
408
+ tasks: Task[];
409
+ checkedUids: Set<string>;
410
+ onToggleChecked: (uid: string) => void;
411
+ onToggle: (task: Task) => void;
412
+ onDelete: (task: Task) => void;
413
+ }
414
+
415
+ function TaskGroup({ label, tasks, checkedUids, onToggleChecked, onToggle, onDelete }: TaskGroupProps) {
416
+ return (
417
+ <div>
418
+ <h2 className="text-xs font-bold uppercase tracking-wide text-text-muted mb-2">{label}</h2>
419
+ <ul className="bg-surface border border-border rounded-md divide-y divide-border">
420
+ {tasks.map((task) => (
421
+ <li key={task.uid} className="flex items-center gap-3 py-2.5 px-3">
422
+ <input
423
+ type="checkbox"
424
+ aria-label={`Select ${task.title}`}
425
+ checked={checkedUids.has(task.uid)}
426
+ onChange={() => onToggleChecked(task.uid)}
427
+ />
428
+ <CompletionToggle task={task} onToggle={onToggle} />
429
+ <span className={["flex-1 text-sm", task.completed ? "line-through text-text-muted" : ""].join(" ")}>
430
+ {task.title}
431
+ </span>
432
+ {task.priority !== "normal" && (
433
+ <span className={["text-xs font-medium", PRIORITY_CLASS[task.priority]].join(" ")}>
434
+ {PRIORITY_LABEL[task.priority]}
435
+ </span>
436
+ )}
437
+ {task.dueDate && (
438
+ <span className="text-xs text-text-muted shrink-0">{new Date(task.dueDate).toLocaleDateString()}</span>
439
+ )}
440
+ <button
441
+ type="button"
442
+ onClick={() => onDelete(task)}
443
+ aria-label={`Delete "${task.title}"`}
444
+ className="text-text-muted hover:text-danger text-sm px-1"
445
+ >
446
+ &times;
447
+ </button>
448
+ </li>
449
+ ))}
450
+ </ul>
451
+ </div>
452
+ );
453
+ }
454
+
455
+ interface TaskTableProps {
456
+ tasks: Task[];
457
+ checkedUids: Set<string>;
458
+ onToggleChecked: (uid: string) => void;
459
+ onToggle: (task: Task) => void;
460
+ onDelete: (task: Task) => void;
461
+ }
462
+
463
+ function TaskTable({ tasks, checkedUids, onToggleChecked, onToggle, onDelete }: TaskTableProps) {
464
+ const allChecked = tasks.length > 0 && tasks.every((t) => checkedUids.has(t.uid));
465
+
466
+ function toggleAll() {
467
+ if (allChecked) {
468
+ // Every task is already checked when this branch runs (that's what `allChecked` means) — no
469
+ // per-task guard needed, unlike the `else` branch below where only *some* tasks may be checked.
470
+ for (const t of tasks) {
471
+ onToggleChecked(t.uid);
472
+ }
473
+ } else {
474
+ for (const t of tasks) {
475
+ if (!checkedUids.has(t.uid)) {
476
+ onToggleChecked(t.uid);
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ return (
483
+ <div className="overflow-x-auto">
484
+ <table className="w-full text-sm bg-surface border border-border rounded-md">
485
+ <thead>
486
+ <tr className="border-b border-border text-left">
487
+ <th className="w-8 px-3 py-2">
488
+ <input type="checkbox" aria-label="Select all tasks" checked={allChecked} onChange={toggleAll} />
489
+ </th>
490
+ <th className="w-8 px-3 py-2" />
491
+ <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Title</th>
492
+ <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Due Date</th>
493
+ <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Importance</th>
494
+ <th className="w-8 px-3 py-2" />
495
+ </tr>
496
+ </thead>
497
+ <tbody>
498
+ {tasks.map((task) => (
499
+ <tr key={task.uid} className="border-b border-border last:border-0">
500
+ <td className="px-3 py-2">
501
+ <input
502
+ type="checkbox"
503
+ aria-label={`Select ${task.title}`}
504
+ checked={checkedUids.has(task.uid)}
505
+ onChange={() => onToggleChecked(task.uid)}
506
+ />
507
+ </td>
508
+ <td className="px-3 py-2">
509
+ <CompletionToggle task={task} onToggle={onToggle} />
510
+ </td>
511
+ <td className={["px-3 py-2", task.completed ? "line-through text-text-muted" : ""].join(" ")}>{task.title}</td>
512
+ <td className="px-3 py-2 text-text-muted">{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : ""}</td>
513
+ <td className={["px-3 py-2", PRIORITY_CLASS[task.priority]].join(" ")}>{PRIORITY_LABEL[task.priority]}</td>
514
+ <td className="px-3 py-2">
515
+ <button
516
+ type="button"
517
+ onClick={() => onDelete(task)}
518
+ aria-label={`Delete "${task.title}"`}
519
+ className="text-text-muted hover:text-danger text-sm px-1"
520
+ >
521
+ &times;
522
+ </button>
523
+ </td>
524
+ </tr>
525
+ ))}
526
+ </tbody>
527
+ </table>
528
+ </div>
529
+ );
530
+ }
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export default function NotFoundPage() {
3
+ return _jsx("p", { children: "Page not found" });
4
+ }
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export default function ErrorPage() {
3
+ return _jsx("p", { children: "Internal server error" });
4
+ }
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/react-shared/branding/useBranding.js";
3
+ export default function Layout({ children, branding }) {
4
+ const title = branding?.title || branding?.companyName
5
+ ? `${branding?.title || branding?.companyName}: Mail Admin Console`
6
+ : "RapidMX: Mail Admin Console";
7
+ const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
8
+ const stylesheetHref = branding?.stylesheetUrl;
9
+ return (_jsxs("html", { lang: "en", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }), _jsx("title", { children: title }), _jsx("link", { rel: "icon", type: "image/svg+xml", href: iconHref }), _jsx("link", { rel: "alternate icon", href: "/favicon.ico" }), _jsx("link", { rel: "preconnect", href: "https://fonts.googleapis.com" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), _jsx("link", { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" }), stylesheetHref && _jsx("link", { rel: "stylesheet", href: stylesheetHref, id: CUSTOM_STYLESHEET_LINK_ID })] }), _jsx("body", { children: children })] }));
10
+ }
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { listAuditLog } from "@rapidmx/react-shared/admin/auditLogApi.js";
9
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ const PAGE_SIZE = 25;
13
+ const FILTER_KEYS = ["mailboxUid", "actorUserUid", "action", "targetType"];
14
+ /** Seeds the filter form from the query string on first render — lets a link (e.g. a future "View audit
15
+ * log" deep link from a mailbox's detail page) land here pre-filtered, same convention as `quarantine`'s
16
+ * `readMailboxUid()`. Filters are also written back to the URL as they change, so the filtered view
17
+ * itself is shareable/bookmarkable. */
18
+ export function readFiltersFromUrl() {
19
+ if (typeof window === "undefined")
20
+ return {};
21
+ const params = new URLSearchParams(window.location.search);
22
+ const filters = {};
23
+ for (const key of FILTER_KEYS) {
24
+ const value = params.get(key);
25
+ if (value)
26
+ filters[key] = value;
27
+ }
28
+ return filters;
29
+ }
30
+ 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";
31
+ export default function AuditLogPage(props) {
32
+ return (_jsx(AdminShell, { ...props, active: "auditLog", children: _jsx(AuditLogContent, {}) }));
33
+ }
34
+ function AuditLogContent() {
35
+ const [filters, setFilters] = useState({});
36
+ const [page, setPage] = useState(0);
37
+ const [entries, setEntries] = useState([]);
38
+ const [loading, setLoading] = useState(true);
39
+ const [error, setError] = useState(null);
40
+ useEffect(() => {
41
+ setFilters(readFiltersFromUrl());
42
+ }, []);
43
+ useEffect(() => {
44
+ setLoading(true);
45
+ setError(null);
46
+ listAuditLog(filters, { page, limit: PAGE_SIZE })
47
+ .then(setEntries)
48
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load the audit log."))
49
+ .finally(() => setLoading(false));
50
+ }, [filters, page]);
51
+ function updateFilter(key, value) {
52
+ const next = { ...filters, [key]: value || undefined };
53
+ setFilters(next);
54
+ setPage(0);
55
+ const params = new URLSearchParams();
56
+ for (const k of FILTER_KEYS) {
57
+ const v = next[k];
58
+ if (v)
59
+ params.set(k, v);
60
+ }
61
+ const query = params.toString();
62
+ window.history.replaceState(null, "", query ? `${window.location.pathname}?${query}` : window.location.pathname);
63
+ }
64
+ const hasNextPage = entries.length === PAGE_SIZE;
65
+ return (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-5", children: "Audit log" }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 mb-5", children: [_jsx("input", { "aria-label": "Mailbox uid", className: INPUT_CLASS, placeholder: "Mailbox uid", value: filters.mailboxUid ?? "", onChange: (e) => updateFilter("mailboxUid", e.target.value) }), _jsx("input", { "aria-label": "Actor user uid", className: INPUT_CLASS, placeholder: "Actor user uid", value: filters.actorUserUid ?? "", onChange: (e) => updateFilter("actorUserUid", e.target.value) }), _jsx("input", { "aria-label": "Action", className: INPUT_CLASS, placeholder: "Action (e.g. domain.create)", value: filters.action ?? "", onChange: (e) => updateFilter("action", e.target.value) }), _jsx("input", { "aria-label": "Target type", className: INPUT_CLASS, placeholder: "Target type (e.g. Domain)", value: filters.targetType ?? "", onChange: (e) => updateFilter("targetType", e.target.value) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : entries.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No matching audit log entries." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["When", "Actor", "Action", "Target", "Mailbox", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: entries.map((entry) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: new Date(entry.dateCreated).toLocaleString() }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.actorUserUid ?? "System" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.action }), _jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [entry.targetType, " \u00B7 ", entry.targetUid] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.mailboxUid ?? "—" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.details && (_jsxs("details", { children: [_jsx("summary", { className: "text-primary-dark hover:underline cursor-pointer", children: "Details" }), _jsx("pre", { className: "text-xs bg-surface-alt border border-border rounded-sm p-2 mt-2 overflow-x-auto", children: JSON.stringify(entry.details, null, 2) })] })) })] }, entry.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
66
+ }