@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.
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- 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’s encrypted mail when they all agree to. It’s optional. Without it, mail that
|
|
157
|
+
someone encrypts can’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’t stored
|
|
222
|
+
anywhere else - if it’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’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…</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’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} <{mailbox.primarySmtpAddress}>
|
|
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
|
+
}
|