@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
@@ -0,0 +1,374 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ChangeEvent, FormEvent, useRef, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ Branding,
9
+ deleteBrandingIcon,
10
+ deleteBrandingLogo,
11
+ deleteBrandingStylesheet,
12
+ updateBranding,
13
+ uploadBrandingIcon,
14
+ uploadBrandingLogo,
15
+ uploadBrandingStylesheet,
16
+ } from "@rapidmx/react-shared/branding/brandingApi.js";
17
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
18
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
19
+
20
+ const INPUT_CLASS =
21
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
22
+
23
+ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
24
+
25
+ /** The branding editor, shared by the Branding page and the setup wizard. */
26
+ export default function BrandingForm({ branding, onChange }: { branding: Branding; onChange: (b: Branding) => void }) {
27
+ const [companyName, setCompanyName] = useState(branding.companyName);
28
+ const [title, setTitle] = useState(branding.title);
29
+ const [headerHtml, setHeaderHtml] = useState(branding.headerHtml ?? "");
30
+ const [footerHtml, setFooterHtml] = useState(branding.footerHtml ?? "");
31
+ const [logoUrlInput, setLogoUrlInput] = useState(branding.logoUrl ?? "");
32
+ const [iconUrlInput, setIconUrlInput] = useState(branding.iconUrl ?? "");
33
+ const [stylesheetUrlInput, setStylesheetUrlInput] = useState(branding.stylesheetUrl ?? "");
34
+
35
+ const [error, setError] = useState<string | null>(null);
36
+ const [saving, setSaving] = useState(false);
37
+ const [saved, setSaved] = useState(false);
38
+ const [assetBusy, setAssetBusy] = useState<string | null>(null);
39
+
40
+ const logoFileRef = useRef<HTMLInputElement>(null);
41
+ const iconFileRef = useRef<HTMLInputElement>(null);
42
+ const stylesheetFileRef = useRef<HTMLInputElement>(null);
43
+
44
+ function runAsset(name: string, action: () => Promise<Branding>) {
45
+ setError(null);
46
+ setAssetBusy(name);
47
+ return action()
48
+ .then((updated) => {
49
+ onChange(updated);
50
+ setLogoUrlInput(updated.logoUrl ?? "");
51
+ setIconUrlInput(updated.iconUrl ?? "");
52
+ setStylesheetUrlInput(updated.stylesheetUrl ?? "");
53
+ })
54
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not update branding."))
55
+ .finally(() => setAssetBusy(null));
56
+ }
57
+
58
+ function handleLogoFileChange(e: ChangeEvent<HTMLInputElement>) {
59
+ const file = e.target.files?.[0];
60
+ if (!file) return;
61
+ if (file.size > MAX_UPLOAD_BYTES) {
62
+ setError(`"${file.name}" is too large — logos must be 5MB or smaller.`);
63
+ } else {
64
+ void runAsset("logo-upload", () => uploadBrandingLogo(file));
65
+ }
66
+ e.target.value = "";
67
+ }
68
+
69
+ function handleIconFileChange(e: ChangeEvent<HTMLInputElement>) {
70
+ const file = e.target.files?.[0];
71
+ if (!file) return;
72
+ if (file.size > MAX_UPLOAD_BYTES) {
73
+ setError(`"${file.name}" is too large — icons must be 5MB or smaller.`);
74
+ } else {
75
+ void runAsset("icon-upload", () => uploadBrandingIcon(file));
76
+ }
77
+ e.target.value = "";
78
+ }
79
+
80
+ function handleStylesheetFileChange(e: ChangeEvent<HTMLInputElement>) {
81
+ const file = e.target.files?.[0];
82
+ if (!file) return;
83
+ if (file.size > MAX_UPLOAD_BYTES) {
84
+ setError(`"${file.name}" is too large — stylesheets must be 5MB or smaller.`);
85
+ } else {
86
+ void runAsset("stylesheet-upload", () => uploadBrandingStylesheet(file));
87
+ }
88
+ e.target.value = "";
89
+ }
90
+
91
+ async function handleSubmit(e: FormEvent) {
92
+ e.preventDefault();
93
+ setError(null);
94
+ setSaved(false);
95
+ setSaving(true);
96
+ try {
97
+ const updated = await updateBranding({ companyName, title, headerHtml, footerHtml });
98
+ onChange(updated);
99
+ setSaved(true);
100
+ } catch (err) {
101
+ setError(err instanceof ApiRequestError ? err.message : "Could not save branding.");
102
+ } finally {
103
+ setSaving(false);
104
+ }
105
+ }
106
+
107
+ return (
108
+ <div className="max-w-2xl">
109
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Branding</h1>
110
+ <p className="text-sm text-text-muted mb-5">
111
+ Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the
112
+ webmail and admin console — including anonymous booking-page visitors.
113
+ </p>
114
+
115
+ {error && <Alert>{error}</Alert>}
116
+ {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
117
+
118
+ <div className="flex flex-col gap-6">
119
+ <section className="flex flex-col gap-3">
120
+ <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Logo</h2>
121
+ <div className="flex items-center gap-4">
122
+ <img
123
+ src={branding.logoUrl || "/images/logo.svg"}
124
+ alt="Current logo"
125
+ width="64"
126
+ height="64"
127
+ className="border border-border rounded-sm bg-surface-alt p-1"
128
+ />
129
+ <div className="flex flex-col gap-2">
130
+ <Button
131
+ type="button"
132
+ variant="secondary"
133
+ className="!w-auto"
134
+ loading={assetBusy === "logo-upload"}
135
+ disabled={assetBusy !== null}
136
+ onClick={() => logoFileRef.current?.click()}
137
+ >
138
+ Upload logo
139
+ </Button>
140
+ <input
141
+ ref={logoFileRef}
142
+ type="file"
143
+ accept="image/*"
144
+ aria-label="Upload logo"
145
+ className="hidden"
146
+ onChange={handleLogoFileChange}
147
+ />
148
+ {branding.logoUrl && (
149
+ <Button
150
+ type="button"
151
+ variant="text"
152
+ disabled={assetBusy !== null}
153
+ loading={assetBusy === "logo-delete"}
154
+ onClick={() => void runAsset("logo-delete", async () => {
155
+ await deleteBrandingLogo();
156
+ return { ...branding, logoUrl: undefined };
157
+ })}
158
+ >
159
+ Remove logo
160
+ </Button>
161
+ )}
162
+ </div>
163
+ </div>
164
+ <label className="flex flex-col gap-1.5 text-sm">
165
+ <span className="font-semibold">Or use an external image URL</span>
166
+ <div className="flex gap-2">
167
+ <input
168
+ aria-label="Logo URL"
169
+ className={INPUT_CLASS}
170
+ value={logoUrlInput}
171
+ onChange={(e) => setLogoUrlInput(e.target.value)}
172
+ />
173
+ <Button
174
+ type="button"
175
+ variant="secondary"
176
+ className="!w-auto"
177
+ disabled={assetBusy !== null || logoUrlInput === (branding.logoUrl ?? "")}
178
+ loading={assetBusy === "logo-url"}
179
+ onClick={() => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput }))}
180
+ >
181
+ Set
182
+ </Button>
183
+ </div>
184
+ </label>
185
+ </section>
186
+
187
+ <section className="flex flex-col gap-3">
188
+ <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Icon</h2>
189
+ <p className="text-xs text-text-muted -mt-1">
190
+ A compact mark for navigation headers, independent of the full logo above. Falls back to
191
+ the logo, then a default asset, when not set.
192
+ </p>
193
+ <div className="flex items-center gap-4">
194
+ <img
195
+ src={branding.iconUrl || branding.logoUrl || "/images/logo.svg"}
196
+ alt="Current icon"
197
+ width="64"
198
+ height="64"
199
+ className="border border-border rounded-sm bg-surface-alt p-1"
200
+ />
201
+ <div className="flex flex-col gap-2">
202
+ <Button
203
+ type="button"
204
+ variant="secondary"
205
+ className="!w-auto"
206
+ loading={assetBusy === "icon-upload"}
207
+ disabled={assetBusy !== null}
208
+ onClick={() => iconFileRef.current?.click()}
209
+ >
210
+ Upload icon
211
+ </Button>
212
+ <input
213
+ ref={iconFileRef}
214
+ type="file"
215
+ accept="image/*"
216
+ aria-label="Upload icon"
217
+ className="hidden"
218
+ onChange={handleIconFileChange}
219
+ />
220
+ {branding.iconUrl && (
221
+ <Button
222
+ type="button"
223
+ variant="text"
224
+ disabled={assetBusy !== null}
225
+ loading={assetBusy === "icon-delete"}
226
+ onClick={() => void runAsset("icon-delete", async () => {
227
+ await deleteBrandingIcon();
228
+ return { ...branding, iconUrl: undefined };
229
+ })}
230
+ >
231
+ Remove icon
232
+ </Button>
233
+ )}
234
+ </div>
235
+ </div>
236
+ <label className="flex flex-col gap-1.5 text-sm">
237
+ <span className="font-semibold">Or use an external image URL</span>
238
+ <div className="flex gap-2">
239
+ <input
240
+ aria-label="Icon URL"
241
+ className={INPUT_CLASS}
242
+ value={iconUrlInput}
243
+ onChange={(e) => setIconUrlInput(e.target.value)}
244
+ />
245
+ <Button
246
+ type="button"
247
+ variant="secondary"
248
+ className="!w-auto"
249
+ disabled={assetBusy !== null || iconUrlInput === (branding.iconUrl ?? "")}
250
+ loading={assetBusy === "icon-url"}
251
+ onClick={() => void runAsset("icon-url", () => updateBranding({ iconUrl: iconUrlInput }))}
252
+ >
253
+ Set
254
+ </Button>
255
+ </div>
256
+ </label>
257
+ </section>
258
+
259
+ <section className="flex flex-col gap-3">
260
+ <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Custom stylesheet</h2>
261
+ <div className="flex items-center gap-3">
262
+ <span className="text-sm text-text-muted truncate">
263
+ {branding.stylesheetUrl || "None configured"}
264
+ </span>
265
+ </div>
266
+ <div className="flex gap-2">
267
+ <Button
268
+ type="button"
269
+ variant="secondary"
270
+ className="!w-auto"
271
+ loading={assetBusy === "stylesheet-upload"}
272
+ disabled={assetBusy !== null}
273
+ onClick={() => stylesheetFileRef.current?.click()}
274
+ >
275
+ Upload CSS file
276
+ </Button>
277
+ <input
278
+ ref={stylesheetFileRef}
279
+ type="file"
280
+ accept="text/css,.css"
281
+ aria-label="Upload stylesheet"
282
+ className="hidden"
283
+ onChange={handleStylesheetFileChange}
284
+ />
285
+ {branding.stylesheetUrl && (
286
+ <Button
287
+ type="button"
288
+ variant="text"
289
+ disabled={assetBusy !== null}
290
+ loading={assetBusy === "stylesheet-delete"}
291
+ onClick={() => void runAsset("stylesheet-delete", async () => {
292
+ await deleteBrandingStylesheet();
293
+ return { ...branding, stylesheetUrl: undefined };
294
+ })}
295
+ >
296
+ Remove stylesheet
297
+ </Button>
298
+ )}
299
+ </div>
300
+ <label className="flex flex-col gap-1.5 text-sm">
301
+ <span className="font-semibold">Or use an external stylesheet URL</span>
302
+ <div className="flex gap-2">
303
+ <input
304
+ aria-label="Stylesheet URL"
305
+ className={INPUT_CLASS}
306
+ value={stylesheetUrlInput}
307
+ onChange={(e) => setStylesheetUrlInput(e.target.value)}
308
+ />
309
+ <Button
310
+ type="button"
311
+ variant="secondary"
312
+ className="!w-auto"
313
+ disabled={assetBusy !== null || stylesheetUrlInput === (branding.stylesheetUrl ?? "")}
314
+ loading={assetBusy === "stylesheet-url"}
315
+ onClick={() =>
316
+ void runAsset("stylesheet-url", () => updateBranding({ stylesheetUrl: stylesheetUrlInput }))
317
+ }
318
+ >
319
+ Set
320
+ </Button>
321
+ </div>
322
+ </label>
323
+ </section>
324
+
325
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2 border-t border-border">
326
+ <label className="flex flex-col gap-1.5 text-sm">
327
+ <span className="font-semibold">Company name</span>
328
+ <input
329
+ aria-label="Company name"
330
+ className={INPUT_CLASS}
331
+ value={companyName}
332
+ onChange={(e) => setCompanyName(e.target.value)}
333
+ />
334
+ </label>
335
+ <label className="flex flex-col gap-1.5 text-sm">
336
+ <span className="font-semibold">Product title</span>
337
+ <input
338
+ aria-label="Product title"
339
+ className={INPUT_CLASS}
340
+ value={title}
341
+ onChange={(e) => setTitle(e.target.value)}
342
+ />
343
+ <span className="text-xs text-text-muted">Shown as the browser tab title.</span>
344
+ </label>
345
+ <label className="flex flex-col gap-1.5 text-sm">
346
+ <span className="font-semibold">Header HTML (optional)</span>
347
+ <textarea
348
+ aria-label="Header HTML"
349
+ className={`${INPUT_CLASS} font-mono`}
350
+ rows={3}
351
+ value={headerHtml}
352
+ onChange={(e) => setHeaderHtml(e.target.value)}
353
+ />
354
+ </label>
355
+ <label className="flex flex-col gap-1.5 text-sm">
356
+ <span className="font-semibold">Footer HTML (optional)</span>
357
+ <textarea
358
+ aria-label="Footer HTML"
359
+ className={`${INPUT_CLASS} font-mono`}
360
+ rows={3}
361
+ value={footerHtml}
362
+ onChange={(e) => setFooterHtml(e.target.value)}
363
+ />
364
+ </label>
365
+ <div>
366
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
367
+ Save
368
+ </Button>
369
+ </div>
370
+ </form>
371
+ </div>
372
+ </div>
373
+ );
374
+ }
@@ -0,0 +1,176 @@
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 { DnsRecordCheck, Domain, getDnsSetup, getDomain, verifyDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+
11
+ const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
12
+ ownership: "Ownership (TXT)",
13
+ mx: "MX",
14
+ spf: "SPF",
15
+ dkim: "DKIM",
16
+ dmarc: "DMARC",
17
+ };
18
+
19
+ export interface DomainDnsSetupProps {
20
+ uid: string;
21
+ /** Called with the domain every time it's (re)loaded - e.g. after "Verify now". */
22
+ onLoaded?: (domain: Domain) => void;
23
+ }
24
+
25
+ /**
26
+ * A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain
27
+ * detail page and the setup wizard.
28
+ */
29
+ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
30
+ const [domain, setDomain] = useState<Domain | null>(null);
31
+ const [dnsSetup, setDnsSetup] = useState<DnsRecordCheck[]>([]);
32
+ const [loading, setLoading] = useState(true);
33
+ const [error, setError] = useState<string | null>(null);
34
+ const [verifying, setVerifying] = useState(false);
35
+ const [copied, setCopied] = useState(false);
36
+
37
+ function reload(id: string) {
38
+ setLoading(true);
39
+ setError(null);
40
+ Promise.all([getDomain(id), getDnsSetup(id)])
41
+ .then(([d, checks]) => {
42
+ setDomain(d);
43
+ setDnsSetup(checks);
44
+ onLoaded?.(d);
45
+ })
46
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this domain."))
47
+ .finally(() => setLoading(false));
48
+ }
49
+
50
+ useEffect(() => {
51
+ reload(uid);
52
+ }, [uid]);
53
+
54
+ // Only ever invoked from the "Verify now" button below, which only renders once `domain` is loaded.
55
+ async function handleVerify() {
56
+ setVerifying(true);
57
+ setError(null);
58
+ try {
59
+ await verifyDomain(domain!.uid);
60
+ reload(domain!.uid);
61
+ } catch (err) {
62
+ setError(err instanceof ApiRequestError ? err.message : "Could not verify this domain.");
63
+ } finally {
64
+ setVerifying(false);
65
+ }
66
+ }
67
+
68
+ async function handleCopy(value: string) {
69
+ try {
70
+ await navigator.clipboard.writeText(value);
71
+ setCopied(true);
72
+ setTimeout(() => setCopied(false), 2000);
73
+ } catch {
74
+ // Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
75
+ }
76
+ }
77
+
78
+ if (loading) {
79
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
80
+ }
81
+ if (error || !domain) {
82
+ return <Alert>{error ?? "Domain not found."}</Alert>;
83
+ }
84
+
85
+ const ownershipValue =
86
+ dnsSetup.find((c) => c.type === "ownership")?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
87
+
88
+ return (
89
+ <div className="flex flex-col gap-5">
90
+ <div className="bg-surface border border-border rounded-md p-6">
91
+ <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
92
+ <dt className="text-text-muted">Enabled</dt>
93
+ <dd>{domain.enabled ? "Yes" : "No"}</dd>
94
+ <dt className="text-text-muted">Verification</dt>
95
+ <dd>
96
+ {domain.verified ? (
97
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white">
98
+ Verified
99
+ </span>
100
+ ) : (
101
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
102
+ Unverified
103
+ </span>
104
+ )}
105
+ </dd>
106
+ <dt className="text-text-muted">Last checked</dt>
107
+ <dd>{domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never"}</dd>
108
+ </dl>
109
+
110
+ {!domain.verified && (
111
+ <div className="mt-5 pt-5 border-t border-border">
112
+ <p className="text-sm mb-2">
113
+ Add the following TXT record to <strong>{domain.name}</strong> to prove ownership, then verify:
114
+ </p>
115
+ <div className="flex items-center gap-2">
116
+ <code className="flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap">
117
+ {ownershipValue}
118
+ </code>
119
+ <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => handleCopy(ownershipValue)}>
120
+ {copied ? "Copied" : "Copy"}
121
+ </Button>
122
+ </div>
123
+ <Button type="button" className="!w-auto mt-3" loading={verifying} disabled={verifying} onClick={handleVerify}>
124
+ Verify now
125
+ </Button>
126
+ </div>
127
+ )}
128
+ </div>
129
+
130
+ {dnsSetup.length > 0 && (
131
+ <div className="bg-surface border border-border rounded-md p-6">
132
+ <h2 className="text-sm font-bold uppercase tracking-wide mb-3">DNS setup checklist</h2>
133
+ <div className="overflow-x-auto">
134
+ <table className="w-full text-sm border-collapse">
135
+ <thead>
136
+ <tr>
137
+ {["Record", "Status", "Recommended value"].map((h) => (
138
+ <th
139
+ key={h}
140
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
141
+ >
142
+ {h}
143
+ </th>
144
+ ))}
145
+ </tr>
146
+ </thead>
147
+ <tbody>
148
+ {dnsSetup.map((check) => (
149
+ <tr key={check.type}>
150
+ <td className="py-2.5 px-2.5 border-b border-border">{RECORD_TYPE_LABELS[check.type]}</td>
151
+ <td className="py-2.5 px-2.5 border-b border-border">
152
+ {!check.configured ? (
153
+ <span className="text-xs text-text-muted">Not configured</span>
154
+ ) : check.matches ? (
155
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white">
156
+ Live
157
+ </span>
158
+ ) : (
159
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
160
+ Not found
161
+ </span>
162
+ )}
163
+ </td>
164
+ <td className="py-2.5 px-2.5 border-b border-border text-text-muted">
165
+ <code className="text-xs">{check.recommendedValue ?? "—"}</code>
166
+ </td>
167
+ </tr>
168
+ ))}
169
+ </tbody>
170
+ </table>
171
+ </div>
172
+ </div>
173
+ )}
174
+ </div>
175
+ );
176
+ }
@@ -0,0 +1,104 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { EncryptionPolicy, PolicyState, updateEncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
8
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+
11
+ const SELECT_CLASS =
12
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
+
14
+ const TIERS: { key: keyof EncryptionPolicy; label: string; help: string }[] = [
15
+ { key: "encryptSameOrg", label: "Mail within this server", help: "Messages between mailboxes hosted on this server." },
16
+ {
17
+ key: "encryptFederated",
18
+ label: "Mail to other RapidMX servers",
19
+ help: "Messages to people on another RapidMX server that publishes its encryption keys.",
20
+ },
21
+ { key: "encryptExternal", label: "Mail to everyone else", help: "Messages to any other email provider, using S/MIME." },
22
+ ];
23
+
24
+ const STATES: { value: PolicyState; label: string }[] = [
25
+ { value: "automatic", label: "Always encrypt when possible" },
26
+ { value: "optional", label: "Let people choose" },
27
+ { value: "prohibited", label: "Never encrypt" },
28
+ ];
29
+
30
+ /** Whether end-to-end encryption can be used at all under `policy` - i.e. any tier allows it. */
31
+ export function isEncryptionEnabled(policy: EncryptionPolicy): boolean {
32
+ return TIERS.some((tier) => policy[tier.key] !== "prohibited");
33
+ }
34
+
35
+ export interface EncryptionPolicyFormProps {
36
+ policy: EncryptionPolicy;
37
+ onChange: (policy: EncryptionPolicy) => void;
38
+ }
39
+
40
+ /** The end-to-end encryption policy editor, shared by the Encryption Policy page and the setup wizard. */
41
+ export default function EncryptionPolicyForm({ policy, onChange }: EncryptionPolicyFormProps) {
42
+ const [values, setValues] = useState<EncryptionPolicy>(policy);
43
+ const [error, setError] = useState<string | null>(null);
44
+ const [saving, setSaving] = useState(false);
45
+ const [saved, setSaved] = useState(false);
46
+
47
+ async function handleSubmit(e: FormEvent) {
48
+ e.preventDefault();
49
+ setError(null);
50
+ setSaved(false);
51
+ setSaving(true);
52
+ try {
53
+ const updated = await updateEncryptionPolicy(values);
54
+ onChange(updated);
55
+ setSaved(true);
56
+ } catch (err) {
57
+ setError(err instanceof ApiRequestError ? err.message : "Could not save the encryption policy.");
58
+ } finally {
59
+ setSaving(false);
60
+ }
61
+ }
62
+
63
+ return (
64
+ <div className="max-w-xl">
65
+ <h2 className="text-lg font-bold uppercase tracking-wide mb-1">End-to-end encryption</h2>
66
+ <p className="text-sm text-text-muted mb-5">
67
+ Encrypted messages can only be read by their senders and recipients - not by this server or its
68
+ administrators. Choose how encryption applies to each kind of mail.
69
+ </p>
70
+
71
+ {error && <Alert>{error}</Alert>}
72
+ {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
73
+
74
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
75
+ {TIERS.map((tier) => (
76
+ <label key={tier.key} className="flex flex-col gap-1.5 text-sm">
77
+ <span className="font-semibold">{tier.label}</span>
78
+ <select
79
+ aria-label={tier.label}
80
+ className={SELECT_CLASS}
81
+ value={values[tier.key]}
82
+ onChange={(e) => {
83
+ setSaved(false);
84
+ setValues({ ...values, [tier.key]: e.target.value as PolicyState });
85
+ }}
86
+ >
87
+ {STATES.map((state) => (
88
+ <option key={state.value} value={state.value}>
89
+ {state.label}
90
+ </option>
91
+ ))}
92
+ </select>
93
+ <span className="text-xs text-text-muted">{tier.help}</span>
94
+ </label>
95
+ ))}
96
+ <div>
97
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
98
+ Save
99
+ </Button>
100
+ </div>
101
+ </form>
102
+ </div>
103
+ );
104
+ }