@rapidmx/web-client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -1,291 +1,28 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useEffect, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import { createMailbox, listMailboxDomains } from "@rapidmx/react-shared/mail/mailApi.js";
8
- import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
9
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
-
13
- const INPUT_CLASS =
14
- "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
- const SELECT_CLASS =
16
- "text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
-
18
- export default function NewMailboxPage(props: Omit<AdminShellProps, "active">) {
19
- return (
20
- <AdminShell {...props} active="mailboxes">
21
- <NewMailboxForm />
22
- </AdminShell>
23
- );
24
- }
25
-
26
- function NewMailboxForm() {
27
- const [primarySmtpAddress, setPrimarySmtpAddress] = useState("");
28
- const [localPart, setLocalPart] = useState("");
29
- const [domains, setDomains] = useState<string[]>([]);
30
- const [domain, setDomain] = useState("");
31
- const [displayName, setDisplayName] = useState("");
32
- const [ownerUserUid, setOwnerUserUid] = useState("");
33
- const [timezone, setTimezone] = useState("UTC");
34
- const [quotaGb, setQuotaGb] = useState(5);
35
- const [isResource, setIsResource] = useState(false);
36
- const [resourceType, setResourceType] = useState<"room" | "equipment">("room");
37
- const [resourceCapacity, setResourceCapacity] = useState("");
38
- const [autoAcceptBookings, setAutoAcceptBookings] = useState(false);
39
- const [allowConflicts, setAllowConflicts] = useState(false);
40
- const [bookingWindowDays, setBookingWindowDays] = useState("");
41
- const [maxDurationMinutes, setMaxDurationMinutes] = useState("");
42
- const [error, setError] = useState<string | null>(null);
43
- const [saving, setSaving] = useState(false);
44
-
45
- // An empty `mail:domains` list (the default) means this server enforces no domain restriction —
46
- // the address field stays free text, exactly as before. Once an admin configures at least one
47
- // domain, every mailbox this server creates must be on one of them (enforced server-side too, in
48
- // `BaseMailboxRoute.create()`), so the form switches to a local-part input + domain dropdown to
49
- // make that restriction visible rather than let an admin discover it only via a rejected submit.
50
- useEffect(() => {
51
- listMailboxDomains()
52
- .then((list) => {
53
- setDomains(list);
54
- setDomain(list[0] ?? "");
55
- })
56
- .catch(() => undefined);
57
- }, []);
58
-
59
- const constrained = domains.length > 0;
60
-
61
- async function handleSubmit(e: FormEvent) {
62
- e.preventDefault();
63
- setError(null);
64
-
65
- const address = constrained ? `${localPart.trim()}@${domain}` : primarySmtpAddress.trim();
66
- if (constrained ? !localPart.trim() : !primarySmtpAddress.trim()) {
67
- setError("A primary SMTP address is required.");
68
- return;
69
- }
70
- if (!displayName.trim()) {
71
- setError("A display name is required.");
72
- return;
73
- }
74
-
75
- setSaving(true);
76
- try {
77
- // ownerUserUid left blank creates a true ownerless shared mailbox (e.g. support@example.com);
78
- // delegates are then granted access from the mailbox's detail page. A resource mailbox is
79
- // ownerless the same way — leave "Owner user uid" blank for one too.
80
- const mailbox = await createMailbox({
81
- primarySmtpAddress: address,
82
- displayName: displayName.trim(),
83
- ownerUserUid: ownerUserUid.trim() || undefined,
84
- timezone,
85
- quotaBytes: Math.round(quotaGb * 1_000_000_000),
86
- ...(isResource && {
87
- isResource: true,
88
- resourceType,
89
- resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
90
- autoAcceptBookings,
91
- allowConflicts,
92
- bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
93
- maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
94
- }),
95
- });
96
- window.location.href = `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`;
97
- } catch (err) {
98
- setError(err instanceof ApiRequestError ? err.message : "Could not create the mailbox.");
99
- } finally {
100
- setSaving(false);
101
- }
102
- }
103
-
104
- return (
105
- <div className="max-w-xl">
106
- <h1 className="text-xl font-bold uppercase tracking-wide mb-1">New mailbox</h1>
107
- <p className="text-sm text-text-muted mb-5">
108
- Leave "Owner user uid" blank to create a true ownerless shared mailbox (the Exchange "shared
109
- mailbox" concept) — access is then granted entirely to delegates afterward, not to a single
110
- owner. Set an owner to create a mailbox for a specific user instead.
111
- </p>
112
-
113
- {error && <Alert>{error}</Alert>}
114
-
115
- <form onSubmit={handleSubmit} className="bg-surface border border-border rounded-md p-6">
116
- {constrained ? (
117
- <FormField label="Local part" htmlFor="localPart">
118
- <div className="flex gap-2 items-center">
119
- <input
120
- id="localPart"
121
- type="text"
122
- className={INPUT_CLASS}
123
- value={localPart}
124
- onChange={(e) => setLocalPart(e.target.value)}
125
- placeholder="support"
126
- />
127
- <span className="text-text-muted shrink-0">@</span>
128
- <select
129
- aria-label="Domain"
130
- className={SELECT_CLASS}
131
- value={domain}
132
- onChange={(e) => setDomain(e.target.value)}
133
- >
134
- {domains.map((d) => (
135
- <option key={d} value={d}>
136
- {d}
137
- </option>
138
- ))}
139
- </select>
140
- </div>
141
- </FormField>
142
- ) : (
143
- <FormField label="Primary SMTP address" htmlFor="primarySmtpAddress">
144
- <input
145
- id="primarySmtpAddress"
146
- type="email"
147
- className={INPUT_CLASS}
148
- value={primarySmtpAddress}
149
- onChange={(e) => setPrimarySmtpAddress(e.target.value)}
150
- placeholder="support@example.com"
151
- />
152
- </FormField>
153
- )}
154
-
155
- <FormField label="Display name" htmlFor="displayName">
156
- <input
157
- id="displayName"
158
- type="text"
159
- className={INPUT_CLASS}
160
- value={displayName}
161
- onChange={(e) => setDisplayName(e.target.value)}
162
- placeholder="Support"
163
- />
164
- </FormField>
165
-
166
- <FormField label="Owner user uid (optional)" htmlFor="ownerUserUid">
167
- <input
168
- id="ownerUserUid"
169
- type="text"
170
- className={INPUT_CLASS}
171
- value={ownerUserUid}
172
- onChange={(e) => setOwnerUserUid(e.target.value)}
173
- placeholder="Leave blank for a shared mailbox"
174
- />
175
- </FormField>
176
-
177
- <FormField label="Timezone" htmlFor="timezone">
178
- <input
179
- id="timezone"
180
- type="text"
181
- className={INPUT_CLASS}
182
- value={timezone}
183
- onChange={(e) => setTimezone(e.target.value)}
184
- placeholder="America/Los_Angeles"
185
- />
186
- </FormField>
187
-
188
- <FormField label="Quota (GB)" htmlFor="quotaGb">
189
- <input
190
- id="quotaGb"
191
- type="number"
192
- min={1}
193
- step={1}
194
- className={INPUT_CLASS}
195
- value={quotaGb}
196
- onChange={(e) => setQuotaGb(Number(e.target.value))}
197
- />
198
- </FormField>
199
-
200
- <label className="flex items-center gap-2 text-sm mb-4">
201
- <input type="checkbox" checked={isResource} onChange={(e) => setIsResource(e.target.checked)} />
202
- This is a resource mailbox (a bookable room or piece of equipment)
203
- </label>
204
-
205
- {isResource && (
206
- <div className="border border-border rounded-sm p-4 mb-4 flex flex-col gap-4">
207
- <FormField label="Resource type" htmlFor="resourceType">
208
- <select
209
- id="resourceType"
210
- className={SELECT_CLASS}
211
- value={resourceType}
212
- onChange={(e) => setResourceType(e.target.value as "room" | "equipment")}
213
- >
214
- <option value="room">Room</option>
215
- <option value="equipment">Equipment</option>
216
- </select>
217
- </FormField>
218
-
219
- <FormField label="Capacity (optional)" htmlFor="resourceCapacity">
220
- <input
221
- id="resourceCapacity"
222
- type="number"
223
- min={0}
224
- step={1}
225
- className={INPUT_CLASS}
226
- value={resourceCapacity}
227
- onChange={(e) => setResourceCapacity(e.target.value)}
228
- placeholder="Informational only"
229
- />
230
- </FormField>
231
-
232
- <label className="flex items-center gap-2 text-sm">
233
- <input
234
- type="checkbox"
235
- checked={autoAcceptBookings}
236
- onChange={(e) => setAutoAcceptBookings(e.target.checked)}
237
- />
238
- Automatically accept booking requests
239
- </label>
240
-
241
- <label className="flex items-center gap-2 text-sm">
242
- <input
243
- type="checkbox"
244
- checked={allowConflicts}
245
- onChange={(e) => setAllowConflicts(e.target.checked)}
246
- />
247
- Allow conflicting bookings (skip conflict checking entirely)
248
- </label>
249
-
250
- <FormField label="Booking window, in days (optional)" htmlFor="bookingWindowDays">
251
- <input
252
- id="bookingWindowDays"
253
- type="number"
254
- min={0}
255
- step={1}
256
- className={INPUT_CLASS}
257
- value={bookingWindowDays}
258
- onChange={(e) => setBookingWindowDays(e.target.value)}
259
- placeholder="No limit"
260
- />
261
- </FormField>
262
-
263
- <FormField label="Maximum duration, in minutes (optional)" htmlFor="maxDurationMinutes">
264
- <input
265
- id="maxDurationMinutes"
266
- type="number"
267
- min={0}
268
- step={1}
269
- className={INPUT_CLASS}
270
- value={maxDurationMinutes}
271
- onChange={(e) => setMaxDurationMinutes(e.target.value)}
272
- placeholder="No limit"
273
- />
274
- </FormField>
275
- </div>
276
- )}
277
-
278
- <div className="flex gap-3 mt-2">
279
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
280
- Create mailbox
281
- </Button>
282
- <a href="/admin">
283
- <Button type="button" variant="secondary" className="!w-auto">
284
- Cancel
285
- </Button>
286
- </a>
287
- </div>
288
- </form>
289
- </div>
290
- );
291
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
7
+ import MailboxCreateForm from "../../../shared/components/admin/settings/MailboxCreateForm.js";
8
+
9
+ export default function NewMailboxPage(props: Omit<AdminShellProps, "active">) {
10
+ return (
11
+ <AdminShell {...props} active="mailboxes">
12
+ <div className="max-w-xl">
13
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">New mailbox</h1>
14
+ <p className="text-sm text-text-muted mb-5">
15
+ Leave "Owner user uid" blank to create a true ownerless shared mailbox (the Exchange "shared
16
+ mailbox" concept) — access is then granted entirely to delegates afterward, not to a single
17
+ owner. Set an owner to create a mailbox for a specific user instead.
18
+ </p>
19
+ <MailboxCreateForm
20
+ cancelHref="/admin"
21
+ onCreated={(mailbox) => {
22
+ window.location.href = `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`;
23
+ }}
24
+ />
25
+ </div>
26
+ </AdminShell>
27
+ );
28
+ }
@@ -0,0 +1,15 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
7
+ import PluginsManager from "../../shared/components/admin/settings/PluginsManager.js";
8
+
9
+ export default function PluginsPage(props: Omit<AdminShellProps, "active">) {
10
+ return (
11
+ <AdminShell {...props} active="plugins">
12
+ <PluginsManager />
13
+ </AdminShell>
14
+ );
15
+ }
@@ -1,132 +1,39 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useEffect, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- MIN_AUDIT_LOG_RETENTION_DAYS,
9
- RetentionPolicy,
10
- getRetentionPolicy,
11
- updateRetentionPolicy,
12
- } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
13
- import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
14
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
15
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
16
-
17
- const INPUT_CLASS =
18
- "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
19
-
20
- export default function RetentionPolicyPage(props: Omit<AdminShellProps, "active">) {
21
- return (
22
- <AdminShell {...props} active="retentionPolicy">
23
- <RetentionPolicyContent />
24
- </AdminShell>
25
- );
26
- }
27
-
28
- function RetentionPolicyContent() {
29
- const [policy, setPolicy] = useState<RetentionPolicy | null>(null);
30
- const [loading, setLoading] = useState(true);
31
- const [loadError, setLoadError] = useState<string | null>(null);
32
-
33
- useEffect(() => {
34
- getRetentionPolicy()
35
- .then(setPolicy)
36
- .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load the retention policy."))
37
- .finally(() => setLoading(false));
38
- }, []);
39
-
40
- if (loading) {
41
- return <p className="text-sm text-text-muted">Loading&hellip;</p>;
42
- }
43
- if (loadError || !policy) {
44
- return <Alert>{loadError}</Alert>;
45
- }
46
- return <RetentionPolicyForm policy={policy} onChange={setPolicy} />;
47
- }
48
-
49
- function RetentionPolicyForm({ policy, onChange }: { policy: RetentionPolicy; onChange: (p: RetentionPolicy) => void }) {
50
- const [messageRetentionDays, setMessageRetentionDays] = useState(policy.messageRetentionDays?.toString() ?? "");
51
- const [auditLogRetentionDays, setAuditLogRetentionDays] = useState(policy.auditLogRetentionDays?.toString() ?? "");
52
- const [error, setError] = useState<string | null>(null);
53
- const [saving, setSaving] = useState(false);
54
- const [saved, setSaved] = useState(false);
55
-
56
- async function handleSubmit(e: FormEvent) {
57
- e.preventDefault();
58
- setError(null);
59
- setSaved(false);
60
- setSaving(true);
61
- try {
62
- const patch: Partial<RetentionPolicy> = {};
63
- if (messageRetentionDays.trim() !== "") {
64
- patch.messageRetentionDays = Number(messageRetentionDays);
65
- }
66
- if (auditLogRetentionDays.trim() !== "") {
67
- patch.auditLogRetentionDays = Number(auditLogRetentionDays);
68
- }
69
- const updated = await updateRetentionPolicy(patch);
70
- onChange(updated);
71
- setSaved(true);
72
- } catch (err) {
73
- setError(err instanceof ApiRequestError ? err.message : "Could not save the retention policy.");
74
- } finally {
75
- setSaving(false);
76
- }
77
- }
78
-
79
- return (
80
- <div className="max-w-xl">
81
- <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Retention Policy</h1>
82
- <p className="text-sm text-text-muted mb-5">
83
- Automatically and permanently deletes mail/audit-log entries older than the configured age.
84
- Leave a field blank for no automatic purge. This does not apply to a mailbox that is a
85
- custodian on an active legal hold — a held message is skipped and retried on a later run once
86
- the hold is lifted.
87
- </p>
88
-
89
- {error && <Alert>{error}</Alert>}
90
- {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
91
-
92
- <form onSubmit={handleSubmit} className="flex flex-col gap-4">
93
- <label className="flex flex-col gap-1.5 text-sm">
94
- <span className="font-semibold">Message retention (days)</span>
95
- <input
96
- aria-label="Message retention (days)"
97
- type="number"
98
- min={1}
99
- className={INPUT_CLASS}
100
- value={messageRetentionDays}
101
- onChange={(e) => setMessageRetentionDays(e.target.value)}
102
- placeholder="No automatic purge"
103
- />
104
- <span className="text-xs text-text-muted">
105
- Applies to any message, in any folder, regardless of age.
106
- </span>
107
- </label>
108
- <label className="flex flex-col gap-1.5 text-sm">
109
- <span className="font-semibold">Audit log retention (days)</span>
110
- <input
111
- aria-label="Audit log retention (days)"
112
- type="number"
113
- min={MIN_AUDIT_LOG_RETENTION_DAYS}
114
- className={INPUT_CLASS}
115
- value={auditLogRetentionDays}
116
- onChange={(e) => setAuditLogRetentionDays(e.target.value)}
117
- placeholder="Keep forever"
118
- />
119
- <span className="text-xs text-text-muted">
120
- Cannot be set below {MIN_AUDIT_LOG_RETENTION_DAYS} days. Never applies to the
121
- tamper-evident escrow audit chain.
122
- </span>
123
- </label>
124
- <div>
125
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
126
- Save
127
- </Button>
128
- </div>
129
- </form>
130
- </div>
131
- );
132
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { RetentionPolicy, getRetentionPolicy } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
8
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
+ import RetentionPolicyForm from "../../shared/components/admin/settings/RetentionPolicyForm.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+
12
+ export default function RetentionPolicyPage(props: Omit<AdminShellProps, "active">) {
13
+ return (
14
+ <AdminShell {...props} active="retentionPolicy">
15
+ <RetentionPolicyContent />
16
+ </AdminShell>
17
+ );
18
+ }
19
+
20
+ function RetentionPolicyContent() {
21
+ const [policy, setPolicy] = useState<RetentionPolicy | null>(null);
22
+ const [loading, setLoading] = useState(true);
23
+ const [loadError, setLoadError] = useState<string | null>(null);
24
+
25
+ useEffect(() => {
26
+ getRetentionPolicy()
27
+ .then(setPolicy)
28
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load the retention policy."))
29
+ .finally(() => setLoading(false));
30
+ }, []);
31
+
32
+ if (loading) {
33
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
34
+ }
35
+ if (loadError || !policy) {
36
+ return <Alert>{loadError}</Alert>;
37
+ }
38
+ return <RetentionPolicyForm policy={policy} onChange={setPolicy} />;
39
+ }
@@ -0,0 +1,15 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
7
+ import SetupWizard from "../../shared/components/admin/setup/SetupWizard.js";
8
+
9
+ export default function SetupPage(props: Omit<AdminShellProps, "active">) {
10
+ return (
11
+ <AdminShell {...props} active="setup">
12
+ <SetupWizard userUid={props.userUid ?? ""} />
13
+ </AdminShell>
14
+ );
15
+ }