@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,98 @@
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 { MIN_AUDIT_LOG_RETENTION_DAYS, RetentionPolicy, updateRetentionPolicy } from "@rapidmx/react-shared/admin/retentionPolicyApi.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 INPUT_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
+ /** The retention policy editor, shared by the Retention Policy page and the setup wizard. */
15
+ export default function RetentionPolicyForm({ policy, onChange }: { policy: RetentionPolicy; onChange: (p: RetentionPolicy) => void }) {
16
+ const [messageRetentionDays, setMessageRetentionDays] = useState(policy.messageRetentionDays?.toString() ?? "");
17
+ const [auditLogRetentionDays, setAuditLogRetentionDays] = useState(policy.auditLogRetentionDays?.toString() ?? "");
18
+ const [error, setError] = useState<string | null>(null);
19
+ const [saving, setSaving] = useState(false);
20
+ const [saved, setSaved] = useState(false);
21
+
22
+ async function handleSubmit(e: FormEvent) {
23
+ e.preventDefault();
24
+ setError(null);
25
+ setSaved(false);
26
+ setSaving(true);
27
+ try {
28
+ const patch: Partial<RetentionPolicy> = {};
29
+ if (messageRetentionDays.trim() !== "") {
30
+ patch.messageRetentionDays = Number(messageRetentionDays);
31
+ }
32
+ if (auditLogRetentionDays.trim() !== "") {
33
+ patch.auditLogRetentionDays = Number(auditLogRetentionDays);
34
+ }
35
+ const updated = await updateRetentionPolicy(patch);
36
+ onChange(updated);
37
+ setSaved(true);
38
+ } catch (err) {
39
+ setError(err instanceof ApiRequestError ? err.message : "Could not save the retention policy.");
40
+ } finally {
41
+ setSaving(false);
42
+ }
43
+ }
44
+
45
+ return (
46
+ <div className="max-w-xl">
47
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Retention Policy</h1>
48
+ <p className="text-sm text-text-muted mb-5">
49
+ Automatically and permanently deletes mail/audit-log entries older than the configured age.
50
+ Leave a field blank for no automatic purge. This does not apply to a mailbox that is a
51
+ custodian on an active legal hold — a held message is skipped and retried on a later run once
52
+ the hold is lifted.
53
+ </p>
54
+
55
+ {error && <Alert>{error}</Alert>}
56
+ {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
57
+
58
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
59
+ <label className="flex flex-col gap-1.5 text-sm">
60
+ <span className="font-semibold">Message retention (days)</span>
61
+ <input
62
+ aria-label="Message retention (days)"
63
+ type="number"
64
+ min={1}
65
+ className={INPUT_CLASS}
66
+ value={messageRetentionDays}
67
+ onChange={(e) => setMessageRetentionDays(e.target.value)}
68
+ placeholder="No automatic purge"
69
+ />
70
+ <span className="text-xs text-text-muted">
71
+ Applies to any message, in any folder, regardless of age.
72
+ </span>
73
+ </label>
74
+ <label className="flex flex-col gap-1.5 text-sm">
75
+ <span className="font-semibold">Audit log retention (days)</span>
76
+ <input
77
+ aria-label="Audit log retention (days)"
78
+ type="number"
79
+ min={MIN_AUDIT_LOG_RETENTION_DAYS}
80
+ className={INPUT_CLASS}
81
+ value={auditLogRetentionDays}
82
+ onChange={(e) => setAuditLogRetentionDays(e.target.value)}
83
+ placeholder="Keep forever"
84
+ />
85
+ <span className="text-xs text-text-muted">
86
+ Cannot be set below {MIN_AUDIT_LOG_RETENTION_DAYS} days. Never applies to the
87
+ tamper-evident escrow audit chain.
88
+ </span>
89
+ </label>
90
+ <div>
91
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
92
+ Save
93
+ </Button>
94
+ </div>
95
+ </form>
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,254 @@
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 { createEscrowScope, EscrowScope, listEscrowScopes } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
8
+ import { generateEscrowKeyPair, GeneratedEscrowKeys } from "@rapidmx/react-shared/crypto/escrowKeys.js";
9
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
13
+ import EscrowScopeKeyAndHoldersFields, {
14
+ emptyEscrowScopeKeyAndHoldersValue,
15
+ EscrowScopeKeyAndHoldersValue,
16
+ } from "../escrowScopes/EscrowScopeKeyAndHoldersFields.js";
17
+
18
+ const INPUT_CLASS =
19
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
20
+
21
+ type Mode = "none" | "generate" | "existing";
22
+
23
+ /** Offers `text` to the browser as a file download. */
24
+ export function downloadTextFile(filename: string, text: string, type: string = "application/x-pem-file"): void {
25
+ const url = URL.createObjectURL(new Blob([text], { type }));
26
+ const link = document.createElement("a");
27
+ link.href = url;
28
+ link.download = filename;
29
+ document.body.appendChild(link);
30
+ link.click();
31
+ link.remove();
32
+ URL.revokeObjectURL(url);
33
+ }
34
+
35
+ function fileSafe(name: string): string {
36
+ return name.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "escrow";
37
+ }
38
+
39
+ /**
40
+ * The setup wizard's escrow step. Escrow lets a separate group of holders recover a mailbox's encrypted mail (for
41
+ * example for legal discovery) using a key this server never has. It's optional, and the administrator can either
42
+ * generate the key pair here (downloading the private key, which never leaves the browser), paste in an existing
43
+ * certificate, or go without escrow.
44
+ */
45
+ export default function EscrowSetupStep() {
46
+ const [scopes, setScopes] = useState<EscrowScope[] | null>(null);
47
+ const [mode, setMode] = useState<Mode>("none");
48
+ const [name, setName] = useState("Escrow");
49
+ const [validYears, setValidYears] = useState(5);
50
+ const [generated, setGenerated] = useState<GeneratedEscrowKeys | null>(null);
51
+ const [savedPrivateKey, setSavedPrivateKey] = useState(false);
52
+ const [keyAndHolders, setKeyAndHolders] = useState<EscrowScopeKeyAndHoldersValue>(emptyEscrowScopeKeyAndHoldersValue());
53
+ const [error, setError] = useState<string | null>(null);
54
+ const [busy, setBusy] = useState(false);
55
+
56
+ useEffect(() => {
57
+ listEscrowScopes({ limit: 100 })
58
+ .then(setScopes)
59
+ .catch(() => setScopes([]));
60
+ }, []);
61
+
62
+ function chooseMode(next: Mode) {
63
+ setMode(next);
64
+ setError(null);
65
+ setGenerated(null);
66
+ setSavedPrivateKey(false);
67
+ setKeyAndHolders(emptyEscrowScopeKeyAndHoldersValue());
68
+ }
69
+
70
+ async function generate() {
71
+ if (!name.trim()) {
72
+ setError("Give the escrow scope a name first.");
73
+ return;
74
+ }
75
+ setBusy(true);
76
+ setError(null);
77
+ try {
78
+ const keys = await generateEscrowKeyPair({ name: name.trim(), validDays: validYears * 365 });
79
+ setGenerated(keys);
80
+ setKeyAndHolders({
81
+ ...emptyEscrowScopeKeyAndHoldersValue(),
82
+ publicKey: keys.publicKey.publicKey,
83
+ keyType: keys.publicKey.type,
84
+ fingerprint: keys.publicKey.fingerprint,
85
+ notBefore: toDatetimeLocal(new Date(keys.publicKey.notBefore).toISOString()),
86
+ notAfter: toDatetimeLocal(new Date(keys.publicKey.notAfter).toISOString()),
87
+ });
88
+ } catch {
89
+ setError("This browser couldn't generate the escrow keys.");
90
+ } finally {
91
+ setBusy(false);
92
+ }
93
+ }
94
+
95
+ async function create(e: FormEvent) {
96
+ e.preventDefault();
97
+ setError(null);
98
+ if (!name.trim()) {
99
+ setError("A name is required.");
100
+ return;
101
+ }
102
+ if (!keyAndHolders.publicKey.trim() || !keyAndHolders.keyType.trim() || !keyAndHolders.fingerprint.trim()) {
103
+ setError("The public key, its type, and its fingerprint are all required.");
104
+ return;
105
+ }
106
+ if (keyAndHolders.holderUserUids.length === 0) {
107
+ setError("At least one holder is required.");
108
+ return;
109
+ }
110
+ if (keyAndHolders.requiredHolders < 1 || keyAndHolders.requiredHolders > keyAndHolders.holderUserUids.length) {
111
+ setError("Required holders must be between 1 and the number of holders.");
112
+ return;
113
+ }
114
+ setBusy(true);
115
+ try {
116
+ const created = await createEscrowScope({
117
+ name: name.trim(),
118
+ publicKey: {
119
+ publicKey: keyAndHolders.publicKey.trim(),
120
+ type: keyAndHolders.keyType.trim(),
121
+ fingerprint: keyAndHolders.fingerprint.trim(),
122
+ notBefore: new Date(keyAndHolders.notBefore).getTime(),
123
+ notAfter: new Date(keyAndHolders.notAfter).getTime(),
124
+ },
125
+ holderUserUids: keyAndHolders.holderUserUids,
126
+ requiredHolders: keyAndHolders.requiredHolders,
127
+ notifySubjectOnAccess: keyAndHolders.notifySubjectOnAccess,
128
+ });
129
+ setScopes((prev) => [...(prev ?? []), created]);
130
+ chooseMode("none");
131
+ setName("Escrow");
132
+ } catch (err) {
133
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the escrow scope.");
134
+ } finally {
135
+ setBusy(false);
136
+ }
137
+ }
138
+
139
+ const scopeForm = (
140
+ <form onSubmit={create} className="flex flex-col gap-5">
141
+ <EscrowScopeKeyAndHoldersFields value={keyAndHolders} onChange={setKeyAndHolders} disabled={mode === "generate" && busy} />
142
+ <div>
143
+ <Button type="submit" loading={busy} disabled={busy} className="!w-auto">
144
+ Create escrow scope
145
+ </Button>
146
+ </div>
147
+ </form>
148
+ );
149
+
150
+ return (
151
+ <div className="flex flex-col gap-5 max-w-3xl">
152
+ <div>
153
+ <h2 className="text-lg font-bold uppercase tracking-wide mb-1">Escrow</h2>
154
+ <p className="text-sm text-text-muted">
155
+ Escrow lets a group of trusted holders - for example your legal or compliance team - recover a
156
+ mailbox&rsquo;s encrypted mail when they all agree to. It&rsquo;s optional. Without it, mail that
157
+ someone encrypts can&rsquo;t be recovered if they lose their keys, by anyone.
158
+ </p>
159
+ </div>
160
+
161
+ {scopes && scopes.length > 0 && (
162
+ <div role="status" className="text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
163
+ Escrow scopes set up: {scopes.map((scope) => scope.name).join(", ")}.
164
+ </div>
165
+ )}
166
+
167
+ {error && <Alert>{error}</Alert>}
168
+
169
+ <fieldset className="flex flex-col gap-2 text-sm">
170
+ <legend className="font-semibold mb-1">How do you want to set up escrow?</legend>
171
+ <label className="flex items-center gap-2">
172
+ <input type="radio" name="escrow-mode" checked={mode === "generate"} onChange={() => chooseMode("generate")} />
173
+ Generate new escrow keys in this browser
174
+ </label>
175
+ <label className="flex items-center gap-2">
176
+ <input type="radio" name="escrow-mode" checked={mode === "existing"} onChange={() => chooseMode("existing")} />
177
+ Use a certificate I already have
178
+ </label>
179
+ <label className="flex items-center gap-2">
180
+ <input type="radio" name="escrow-mode" checked={mode === "none"} onChange={() => chooseMode("none")} />
181
+ {scopes && scopes.length > 0 ? "Don't add another escrow scope" : "Don't use escrow"}
182
+ </label>
183
+ </fieldset>
184
+
185
+ {mode !== "none" && (
186
+ <div className="bg-surface border border-border rounded-md p-6">
187
+ <FormField label="Escrow scope name" htmlFor="escrowName">
188
+ <input
189
+ id="escrowName"
190
+ type="text"
191
+ className={INPUT_CLASS}
192
+ value={name}
193
+ disabled={!!generated}
194
+ onChange={(e) => setName(e.target.value)}
195
+ />
196
+ </FormField>
197
+ {mode === "generate" && !generated && (
198
+ <>
199
+ <FormField label="Keys valid for" htmlFor="escrowValidYears">
200
+ <select
201
+ id="escrowValidYears"
202
+ className={INPUT_CLASS}
203
+ value={validYears}
204
+ onChange={(e) => setValidYears(Number(e.target.value))}
205
+ >
206
+ {[1, 2, 5, 10].map((years) => (
207
+ <option key={years} value={years}>
208
+ {years} {years === 1 ? "year" : "years"}
209
+ </option>
210
+ ))}
211
+ </select>
212
+ </FormField>
213
+ <Button type="button" className="!w-auto" loading={busy} disabled={busy} onClick={() => void generate()}>
214
+ Generate keys
215
+ </Button>
216
+ </>
217
+ )}
218
+ {mode === "generate" && generated && (
219
+ <div className="flex flex-col gap-3 text-sm">
220
+ <Alert>
221
+ Download the private key now and give it to your escrow holders. It isn&rsquo;t stored
222
+ anywhere else - if it&rsquo;s lost, no escrowed mail can ever be recovered.
223
+ </Alert>
224
+ <div className="flex flex-wrap gap-2">
225
+ <Button
226
+ type="button"
227
+ className="!w-auto"
228
+ onClick={() => downloadTextFile(`${fileSafe(name)}-private-key.pem`, generated.privateKeyPem)}
229
+ >
230
+ Download private key
231
+ </Button>
232
+ <Button
233
+ type="button"
234
+ variant="secondary"
235
+ className="!w-auto"
236
+ onClick={() => downloadTextFile(`${fileSafe(name)}-certificate.pem`, generated.certificatePem)}
237
+ >
238
+ Download certificate
239
+ </Button>
240
+ </div>
241
+ <label className="flex items-center gap-2">
242
+ <input type="checkbox" checked={savedPrivateKey} onChange={(e) => setSavedPrivateKey(e.target.checked)} />
243
+ I&rsquo;ve saved the private key somewhere safe
244
+ </label>
245
+ </div>
246
+ )}
247
+ </div>
248
+ )}
249
+
250
+ {mode === "existing" && scopeForm}
251
+ {mode === "generate" && generated && savedPrivateKey && scopeForm}
252
+ </div>
253
+ );
254
+ }
@@ -0,0 +1,300 @@
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 { getRetentionPolicy } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
8
+ import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
9
+ import { createDomain, Domain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
10
+ import { completeSetup, getSetupStatus, saveSetupStep } from "@rapidmx/react-shared/admin/setupApi.js";
11
+ import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
12
+ import { EncryptionPolicy, getEncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
13
+ import { listMailboxes, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
14
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
15
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
16
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
17
+ import BrandingForm from "../settings/BrandingForm.js";
18
+ import DomainDnsSetup from "../settings/DomainDnsSetup.js";
19
+ import EncryptionPolicyForm, { isEncryptionEnabled } from "../settings/EncryptionPolicyForm.js";
20
+ import LoadedSettingsForm from "../settings/LoadedSettingsForm.js";
21
+ import MailboxCreateForm from "../settings/MailboxCreateForm.js";
22
+ import MailboxPolicyForm from "../settings/MailboxPolicyForm.js";
23
+ import PluginsManager from "../settings/PluginsManager.js";
24
+ import RetentionPolicyForm from "../settings/RetentionPolicyForm.js";
25
+ import EscrowSetupStep from "./EscrowSetupStep.js";
26
+
27
+ const INPUT_CLASS =
28
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
29
+
30
+ export const SETUP_STEPS = [
31
+ { id: "plugins", title: "Plugins", intro: "Choose which protocols and features this server runs. The recommended plugins are already installed." },
32
+ { id: "domain", title: "Domain", intro: "Add the domain this server receives mail for, then publish the DNS records below." },
33
+ { id: "settings", title: "Server settings", intro: "Set the rules for encryption, how long mail is kept, and new mailboxes." },
34
+ { id: "escrow", title: "Escrow", intro: "Decide whether encrypted mail can be recovered by a trusted group of holders." },
35
+ { id: "branding", title: "Branding", intro: "Make the sign-in page and apps look like your organization." },
36
+ { id: "mailboxes", title: "Mailboxes", intro: "Create the first mailboxes, starting with your own." },
37
+ ] as const;
38
+
39
+ export type SetupStepId = (typeof SETUP_STEPS)[number]["id"];
40
+
41
+ function isStepId(value: string | undefined): value is SetupStepId {
42
+ return SETUP_STEPS.some((step) => step.id === value);
43
+ }
44
+
45
+ export interface SetupWizardProps {
46
+ userUid: string;
47
+ }
48
+
49
+ /**
50
+ * The first-run setup wizard. Each step saves as the administrator works through it; the wizard itself only records
51
+ * which step they're on (so leaving and coming back resumes there) and, at the end, that setup is finished. Every step
52
+ * except the domain can be passed over and configured later from the admin console.
53
+ */
54
+ export default function SetupWizard({ userUid }: SetupWizardProps) {
55
+ const [step, setStep] = useState<SetupStepId | null>(null);
56
+ const [domains, setDomains] = useState<Domain[]>([]);
57
+ const [encryption, setEncryption] = useState<EncryptionPolicy | null>(null);
58
+ const [finishing, setFinishing] = useState(false);
59
+ const [error, setError] = useState<string | null>(null);
60
+
61
+ useEffect(() => {
62
+ void Promise.all([getSetupStatus().catch(() => undefined), listDomains({ limit: 25 }).catch(() => [] as Domain[])]).then(([status, list]) => {
63
+ setDomains(list);
64
+ setStep(isStepId(status?.currentStep) ? status.currentStep : "plugins");
65
+ });
66
+ }, []);
67
+
68
+ useEffect(() => {
69
+ if (step === "escrow" || step === "settings") {
70
+ getEncryptionPolicy()
71
+ .then(setEncryption)
72
+ .catch(() => setEncryption(null));
73
+ }
74
+ }, [step]);
75
+
76
+ if (step === null) {
77
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
78
+ }
79
+
80
+ const index = SETUP_STEPS.findIndex((s) => s.id === step);
81
+ const current = SETUP_STEPS[index];
82
+ const last = index === SETUP_STEPS.length - 1;
83
+
84
+ function goTo(next: SetupStepId) {
85
+ setError(null);
86
+ setStep(next);
87
+ // Progress is a convenience for resuming - failing to record it shouldn't stop the administrator.
88
+ void saveSetupStep(next).catch(() => undefined);
89
+ window.scrollTo?.(0, 0);
90
+ }
91
+
92
+ async function finish() {
93
+ setFinishing(true);
94
+ setError(null);
95
+ try {
96
+ await completeSetup();
97
+ window.location.href = "/admin";
98
+ } catch (err) {
99
+ setError(err instanceof ApiRequestError ? err.message : "Could not finish setup.");
100
+ setFinishing(false);
101
+ }
102
+ }
103
+
104
+ const blocked = step === "domain" && domains.length === 0;
105
+
106
+ return (
107
+ <div className="flex flex-col gap-6">
108
+ <div>
109
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Set up your server</h1>
110
+ <p className="text-sm text-text-muted">You can change any of these settings later from the admin console.</p>
111
+ </div>
112
+
113
+ <ol aria-label="Setup steps" className="flex flex-wrap gap-2">
114
+ {SETUP_STEPS.map((s, i) => (
115
+ <li key={s.id}>
116
+ <button
117
+ type="button"
118
+ aria-current={s.id === step ? "step" : undefined}
119
+ // Steps after the domain need somewhere to put mail first.
120
+ disabled={i > 1 && domains.length === 0}
121
+ onClick={() => goTo(s.id)}
122
+ className={[
123
+ "text-sm py-1.5 px-3 rounded-pill border disabled:opacity-50",
124
+ s.id === step
125
+ ? "bg-primary/10 border-primary text-primary-dark font-semibold"
126
+ : i < index
127
+ ? "border-border text-text"
128
+ : "border-border text-text-muted",
129
+ ].join(" ")}
130
+ >
131
+ {i + 1}. {s.title}
132
+ {i < index && <span className="sr-only"> (done)</span>}
133
+ </button>
134
+ </li>
135
+ ))}
136
+ </ol>
137
+
138
+ <section aria-labelledby="setup-step-title" className="flex flex-col gap-5">
139
+ <div>
140
+ <h2 id="setup-step-title" className="text-lg font-bold">
141
+ Step {index + 1} of {SETUP_STEPS.length}: {current.title}
142
+ </h2>
143
+ <p className="text-sm text-text-muted">{current.intro}</p>
144
+ </div>
145
+
146
+ {step === "plugins" && <PluginsManager />}
147
+ {step === "domain" && <DomainStep domains={domains} onCreated={(domain) => setDomains((prev) => [...prev, domain])} />}
148
+ {step === "settings" && (
149
+ <div className="flex flex-col gap-10">
150
+ <LoadedSettingsForm load={getEncryptionPolicy} loadErrorMessage="Could not load the encryption policy.">
151
+ {(policy, onChange) => (
152
+ <EncryptionPolicyForm
153
+ policy={policy}
154
+ onChange={(updated) => {
155
+ onChange(updated);
156
+ setEncryption(updated);
157
+ }}
158
+ />
159
+ )}
160
+ </LoadedSettingsForm>
161
+ <LoadedSettingsForm load={getRetentionPolicy} loadErrorMessage="Could not load the retention policy.">
162
+ {(policy, onChange) => <RetentionPolicyForm policy={policy} onChange={onChange} />}
163
+ </LoadedSettingsForm>
164
+ <LoadedSettingsForm load={getMailboxPolicy} loadErrorMessage="Could not load the mailbox policy.">
165
+ {(policy, onChange) => <MailboxPolicyForm policy={policy} onChange={onChange} />}
166
+ </LoadedSettingsForm>
167
+ </div>
168
+ )}
169
+ {step === "escrow" &&
170
+ (encryption && !isEncryptionEnabled(encryption) ? (
171
+ <p role="status" className="text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
172
+ End-to-end encryption is turned off, so escrow isn&rsquo;t needed. You can set it up later if you
173
+ turn encryption on.
174
+ </p>
175
+ ) : (
176
+ <EscrowSetupStep />
177
+ ))}
178
+ {step === "branding" && (
179
+ <LoadedSettingsForm load={getBranding} loadErrorMessage="Could not load branding.">
180
+ {(branding, onChange) => <BrandingForm branding={branding} onChange={onChange} />}
181
+ </LoadedSettingsForm>
182
+ )}
183
+ {step === "mailboxes" && <MailboxesStep userUid={userUid} domain={domains.find((d) => d.verified)?.name ?? domains[0]?.name} />}
184
+ </section>
185
+
186
+ {error && <Alert>{error}</Alert>}
187
+
188
+ <div className="flex items-center gap-3 border-t border-border pt-5">
189
+ {index > 0 && (
190
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => goTo(SETUP_STEPS[index - 1].id)}>
191
+ Back
192
+ </Button>
193
+ )}
194
+ <div className="flex-1" />
195
+ {blocked && <span className="text-sm text-text-muted">Add a domain to continue.</span>}
196
+ {last ? (
197
+ <Button type="button" className="!w-auto" loading={finishing} disabled={finishing} onClick={() => void finish()}>
198
+ Finish setup
199
+ </Button>
200
+ ) : (
201
+ <Button type="button" className="!w-auto" disabled={blocked} onClick={() => goTo(SETUP_STEPS[index + 1].id)}>
202
+ Continue
203
+ </Button>
204
+ )}
205
+ </div>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ function DomainStep({ domains, onCreated }: { domains: Domain[]; onCreated: (domain: Domain) => void }) {
211
+ const [name, setName] = useState("");
212
+ const [error, setError] = useState<string | null>(null);
213
+ const [saving, setSaving] = useState(false);
214
+
215
+ async function handleSubmit(e: FormEvent) {
216
+ e.preventDefault();
217
+ setError(null);
218
+ if (!name.trim()) {
219
+ setError("A domain name is required.");
220
+ return;
221
+ }
222
+ setSaving(true);
223
+ try {
224
+ onCreated(await createDomain({ name: name.trim() }));
225
+ setName("");
226
+ } catch (err) {
227
+ setError(err instanceof ApiRequestError ? err.message : "Could not add the domain.");
228
+ } finally {
229
+ setSaving(false);
230
+ }
231
+ }
232
+
233
+ return (
234
+ <div className="flex flex-col gap-5 max-w-3xl">
235
+ {domains.map((domain) => (
236
+ <div key={domain.uid} className="flex flex-col gap-3">
237
+ <h3 className="text-base font-bold">{domain.name}</h3>
238
+ <DomainDnsSetup uid={domain.uid} />
239
+ </div>
240
+ ))}
241
+ {error && <Alert>{error}</Alert>}
242
+ <form onSubmit={handleSubmit} className="bg-surface border border-border rounded-md p-6">
243
+ <FormField label={domains.length === 0 ? "Domain name" : "Add another domain"} htmlFor="setupDomainName">
244
+ <input
245
+ id="setupDomainName"
246
+ type="text"
247
+ className={INPUT_CLASS}
248
+ value={name}
249
+ onChange={(e) => setName(e.target.value)}
250
+ placeholder="example.com"
251
+ />
252
+ </FormField>
253
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
254
+ Add domain
255
+ </Button>
256
+ </form>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function MailboxesStep({ userUid, domain }: { userUid: string; domain?: string }) {
262
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
263
+ const [formKey, setFormKey] = useState(0);
264
+
265
+ useEffect(() => {
266
+ listMailboxes({ limit: 100 })
267
+ .then(setMailboxes)
268
+ .catch(() => undefined);
269
+ }, []);
270
+
271
+ const hasOwn = mailboxes.some((mailbox) => mailbox.ownerUserUid === userUid);
272
+
273
+ return (
274
+ <div className="flex flex-col gap-5 max-w-xl">
275
+ {mailboxes.length > 0 && (
276
+ <div>
277
+ <h3 className="text-sm font-bold uppercase tracking-wide mb-2">Mailboxes created</h3>
278
+ <ul className="text-sm flex flex-col gap-1">
279
+ {mailboxes.map((mailbox) => (
280
+ <li key={mailbox.uid}>
281
+ {mailbox.displayName} &lt;{mailbox.primarySmtpAddress}&gt;
282
+ {mailbox.ownerUserUid === userUid && <span className="text-text-muted"> (yours)</span>}
283
+ </li>
284
+ ))}
285
+ </ul>
286
+ </div>
287
+ )}
288
+ <h3 className="text-base font-bold">{hasOwn ? "Add another mailbox" : "Your mailbox"}</h3>
289
+ <MailboxCreateForm
290
+ key={formKey}
291
+ submitLabel="Create mailbox"
292
+ defaults={hasOwn ? { domain } : { localPart: "admin", domain, displayName: "Administrator", ownerUserUid: userUid }}
293
+ onCreated={(mailbox) => {
294
+ setMailboxes((prev) => [...prev, mailbox]);
295
+ setFormKey((k) => k + 1);
296
+ }}
297
+ />
298
+ </div>
299
+ );
300
+ }