@rapidmx/web-client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -1,16 +1,14 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useRef, useState } from "react";
6
+ import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { deleteBrandingIcon, deleteBrandingLogo, deleteBrandingStylesheet, getBranding, updateBranding, uploadBrandingIcon, uploadBrandingLogo, uploadBrandingStylesheet, } from "@rapidmx/react-shared/branding/brandingApi.js";
8
+ import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
9
9
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
10
+ import BrandingForm from "../../shared/components/admin/settings/BrandingForm.js";
10
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
- const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
- const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
14
12
  export default function BrandingPage(props) {
15
13
  return (_jsx(AdminShell, { ...props, active: "branding", children: _jsx(BrandingContent, {}) }));
16
14
  }
@@ -32,95 +30,3 @@ function BrandingContent() {
32
30
  }
33
31
  return _jsx(BrandingForm, { branding: branding, onChange: setBranding });
34
32
  }
35
- function BrandingForm({ branding, onChange }) {
36
- const [companyName, setCompanyName] = useState(branding.companyName);
37
- const [title, setTitle] = useState(branding.title);
38
- const [headerHtml, setHeaderHtml] = useState(branding.headerHtml ?? "");
39
- const [footerHtml, setFooterHtml] = useState(branding.footerHtml ?? "");
40
- const [logoUrlInput, setLogoUrlInput] = useState(branding.logoUrl ?? "");
41
- const [iconUrlInput, setIconUrlInput] = useState(branding.iconUrl ?? "");
42
- const [stylesheetUrlInput, setStylesheetUrlInput] = useState(branding.stylesheetUrl ?? "");
43
- const [error, setError] = useState(null);
44
- const [saving, setSaving] = useState(false);
45
- const [saved, setSaved] = useState(false);
46
- const [assetBusy, setAssetBusy] = useState(null);
47
- const logoFileRef = useRef(null);
48
- const iconFileRef = useRef(null);
49
- const stylesheetFileRef = useRef(null);
50
- function runAsset(name, action) {
51
- setError(null);
52
- setAssetBusy(name);
53
- return action()
54
- .then((updated) => {
55
- onChange(updated);
56
- setLogoUrlInput(updated.logoUrl ?? "");
57
- setIconUrlInput(updated.iconUrl ?? "");
58
- setStylesheetUrlInput(updated.stylesheetUrl ?? "");
59
- })
60
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not update branding."))
61
- .finally(() => setAssetBusy(null));
62
- }
63
- function handleLogoFileChange(e) {
64
- const file = e.target.files?.[0];
65
- if (!file)
66
- return;
67
- if (file.size > MAX_UPLOAD_BYTES) {
68
- setError(`"${file.name}" is too large — logos must be 5MB or smaller.`);
69
- }
70
- else {
71
- void runAsset("logo-upload", () => uploadBrandingLogo(file));
72
- }
73
- e.target.value = "";
74
- }
75
- function handleIconFileChange(e) {
76
- const file = e.target.files?.[0];
77
- if (!file)
78
- return;
79
- if (file.size > MAX_UPLOAD_BYTES) {
80
- setError(`"${file.name}" is too large — icons must be 5MB or smaller.`);
81
- }
82
- else {
83
- void runAsset("icon-upload", () => uploadBrandingIcon(file));
84
- }
85
- e.target.value = "";
86
- }
87
- function handleStylesheetFileChange(e) {
88
- const file = e.target.files?.[0];
89
- if (!file)
90
- return;
91
- if (file.size > MAX_UPLOAD_BYTES) {
92
- setError(`"${file.name}" is too large — stylesheets must be 5MB or smaller.`);
93
- }
94
- else {
95
- void runAsset("stylesheet-upload", () => uploadBrandingStylesheet(file));
96
- }
97
- e.target.value = "";
98
- }
99
- async function handleSubmit(e) {
100
- e.preventDefault();
101
- setError(null);
102
- setSaved(false);
103
- setSaving(true);
104
- try {
105
- const updated = await updateBranding({ companyName, title, headerHtml, footerHtml });
106
- onChange(updated);
107
- setSaved(true);
108
- }
109
- catch (err) {
110
- setError(err instanceof ApiRequestError ? err.message : "Could not save branding.");
111
- }
112
- finally {
113
- setSaving(false);
114
- }
115
- }
116
- return (_jsxs("div", { className: "max-w-2xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Branding" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the webmail and admin console \u2014 including anonymous booking-page visitors." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Logo" }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.logoUrl || "/images/logo.svg", alt: "Current logo", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "logo-upload", disabled: assetBusy !== null, onClick: () => logoFileRef.current?.click(), children: "Upload logo" }), _jsx("input", { ref: logoFileRef, type: "file", accept: "image/*", "aria-label": "Upload logo", className: "hidden", onChange: handleLogoFileChange }), branding.logoUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "logo-delete", onClick: () => void runAsset("logo-delete", async () => {
117
- await deleteBrandingLogo();
118
- return { ...branding, logoUrl: undefined };
119
- }), children: "Remove logo" }))] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external image URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Logo URL", className: INPUT_CLASS, value: logoUrlInput, onChange: (e) => setLogoUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || logoUrlInput === (branding.logoUrl ?? ""), loading: assetBusy === "logo-url", onClick: () => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput })), children: "Set" })] })] })] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Icon" }), _jsx("p", { className: "text-xs text-text-muted -mt-1", children: "A compact mark for navigation headers, independent of the full logo above. Falls back to the logo, then a default asset, when not set." }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.iconUrl || branding.logoUrl || "/images/logo.svg", alt: "Current icon", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "icon-upload", disabled: assetBusy !== null, onClick: () => iconFileRef.current?.click(), children: "Upload icon" }), _jsx("input", { ref: iconFileRef, type: "file", accept: "image/*", "aria-label": "Upload icon", className: "hidden", onChange: handleIconFileChange }), branding.iconUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "icon-delete", onClick: () => void runAsset("icon-delete", async () => {
120
- await deleteBrandingIcon();
121
- return { ...branding, iconUrl: undefined };
122
- }), children: "Remove icon" }))] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external image URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Icon URL", className: INPUT_CLASS, value: iconUrlInput, onChange: (e) => setIconUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || iconUrlInput === (branding.iconUrl ?? ""), loading: assetBusy === "icon-url", onClick: () => void runAsset("icon-url", () => updateBranding({ iconUrl: iconUrlInput })), children: "Set" })] })] })] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Custom stylesheet" }), _jsx("div", { className: "flex items-center gap-3", children: _jsx("span", { className: "text-sm text-text-muted truncate", children: branding.stylesheetUrl || "None configured" }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "stylesheet-upload", disabled: assetBusy !== null, onClick: () => stylesheetFileRef.current?.click(), children: "Upload CSS file" }), _jsx("input", { ref: stylesheetFileRef, type: "file", accept: "text/css,.css", "aria-label": "Upload stylesheet", className: "hidden", onChange: handleStylesheetFileChange }), branding.stylesheetUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "stylesheet-delete", onClick: () => void runAsset("stylesheet-delete", async () => {
123
- await deleteBrandingStylesheet();
124
- return { ...branding, stylesheetUrl: undefined };
125
- }), children: "Remove stylesheet" }))] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external stylesheet URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Stylesheet URL", className: INPUT_CLASS, value: stylesheetUrlInput, onChange: (e) => setStylesheetUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || stylesheetUrlInput === (branding.stylesheetUrl ?? ""), loading: assetBusy === "stylesheet-url", onClick: () => void runAsset("stylesheet-url", () => updateBranding({ stylesheetUrl: stylesheetUrlInput })), children: "Set" })] })] })] }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4 pt-2 border-t border-border", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Company name" }), _jsx("input", { "aria-label": "Company name", className: INPUT_CLASS, value: companyName, onChange: (e) => setCompanyName(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Product title" }), _jsx("input", { "aria-label": "Product title", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "Shown as the browser tab title." })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Header HTML (optional)" }), _jsx("textarea", { "aria-label": "Header HTML", className: `${INPUT_CLASS} font-mono`, rows: 3, value: headerHtml, onChange: (e) => setHeaderHtml(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Footer HTML (optional)" }), _jsx("textarea", { "aria-label": "Footer HTML", className: `${INPUT_CLASS} font-mono`, rows: 3, value: footerHtml, onChange: (e) => setFooterHtml(e.target.value) })] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] })] }));
126
- }
@@ -3,72 +3,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useState } from "react";
6
+ import { useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { deleteDomain, getDnsSetup, getDomain, verifyDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
+ import { deleteDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
9
9
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
10
+ import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.js";
10
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
12
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
13
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
13
- const RECORD_TYPE_LABELS = {
14
- ownership: "Ownership (TXT)",
15
- mx: "MX",
16
- spf: "SPF",
17
- dkim: "DKIM",
18
- dmarc: "DMARC",
19
- };
20
14
  export default function DomainDetailPage(props) {
21
15
  return (_jsx(AdminShell, { ...props, active: "domains", children: _jsx(DomainDetailContent, { uid: props.params.uid }) }));
22
16
  }
23
17
  function DomainDetailContent({ uid }) {
24
18
  const [domain, setDomain] = useState(null);
25
- const [dnsSetup, setDnsSetup] = useState([]);
26
- const [loading, setLoading] = useState(true);
27
- const [error, setError] = useState(null);
28
- const [verifying, setVerifying] = useState(false);
29
- const [copied, setCopied] = useState(false);
30
19
  const [confirmingDelete, setConfirmingDelete] = useState(false);
31
20
  const [deleting, setDeleting] = useState(false);
32
21
  const [deleteError, setDeleteError] = useState(null);
33
- function reload(id) {
34
- setLoading(true);
35
- setError(null);
36
- Promise.all([getDomain(id), getDnsSetup(id)])
37
- .then(([d, checks]) => {
38
- setDomain(d);
39
- setDnsSetup(checks);
40
- })
41
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this domain."))
42
- .finally(() => setLoading(false));
43
- }
44
- useEffect(() => {
45
- reload(uid);
46
- }, [uid]);
47
- // Only ever invoked from the "Verify now" button below, which itself only renders once `domain` is
48
- // resolved (children only render once loaded — see the early returns above) — the non-null assertion
49
- // reflects that real invariant, not an unchecked assumption. Matches `QuarantineContent.handleRelease`'s
50
- // identical pattern.
51
- async function handleVerify() {
52
- setVerifying(true);
53
- setError(null);
54
- try {
55
- await verifyDomain(domain.uid);
56
- reload(domain.uid);
57
- }
58
- catch (err) {
59
- setError(err instanceof ApiRequestError ? err.message : "Could not verify this domain.");
60
- }
61
- finally {
62
- setVerifying(false);
63
- }
64
- }
65
- // Only ever invoked from the "Delete domain" confirmation modal below, which itself only renders once
66
- // `domain` is resolved (children only render once loaded — see the early returns above) — the non-null
67
- // assertion reflects that real invariant, not an unchecked assumption. Matches `handleVerify`'s identical
68
- // pattern above.
69
22
  function closeDeleteModal() {
70
23
  setConfirmingDelete(false);
71
24
  }
25
+ // Only ever invoked from the "Delete domain" confirmation modal, which only renders once `domain` is loaded.
72
26
  async function handleDelete() {
73
27
  setDeleting(true);
74
28
  setDeleteError(null);
@@ -81,22 +35,5 @@ function DomainDetailContent({ uid }) {
81
35
  setDeleting(false);
82
36
  }
83
37
  }
84
- async function handleCopy(value) {
85
- try {
86
- await navigator.clipboard.writeText(value);
87
- setCopied(true);
88
- setTimeout(() => setCopied(false), 2000);
89
- }
90
- catch {
91
- // Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
92
- }
93
- }
94
- if (loading) {
95
- return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
96
- }
97
- if (error || !domain) {
98
- return _jsx(Alert, { children: error ?? "Domain not found." });
99
- }
100
- const ownershipCheck = dnsSetup.find((c) => c.type === "ownership");
101
- return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("code", { className: "flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap", children: ownershipCheck?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}` }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => handleCopy(ownershipCheck?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`), children: copied ? "Copied" : "Copy" })] }), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: RECORD_TYPE_LABELS[check.type] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: _jsx("code", { className: "text-xs", children: check.recommendedValue ?? "—" }) })] }, check.type))) })] }) })] })), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] })] }));
38
+ return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: setDomain }), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
102
39
  }
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { getEncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
3
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
4
+ import EncryptionPolicyForm from "../../shared/components/admin/settings/EncryptionPolicyForm.js";
5
+ import LoadedSettingsForm from "../../shared/components/admin/settings/LoadedSettingsForm.js";
6
+ export default function EncryptionPolicyPage(props) {
7
+ return (_jsx(AdminShell, { ...props, active: "encryptionPolicy", children: _jsx(LoadedSettingsForm, { load: getEncryptionPolicy, loadErrorMessage: "Could not load the encryption policy.", children: (policy, onChange) => _jsx(EncryptionPolicyForm, { policy: policy, onChange: onChange }) }) }));
8
+ }
@@ -6,6 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { reopenSetup } from "@rapidmx/react-shared/admin/setupApi.js";
9
10
  import AdminShell from "../shared/components/admin/layout/AdminShell.js";
10
11
  import MailboxTable from "../shared/components/admin/mailboxes/MailboxTable.js";
11
12
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
@@ -28,5 +29,17 @@ function MailboxesListContent() {
28
29
  .finally(() => setLoading(false));
29
30
  }, [page]);
30
31
  const hasNextPage = mailboxes.length === PAGE_SIZE;
31
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Mailboxes" }), _jsx("a", { href: "/admin/mailboxes/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New mailbox" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(MailboxTable, { mailboxes: mailboxes })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
32
+ const [reopening, setReopening] = useState(false);
33
+ async function handleRunSetup() {
34
+ setReopening(true);
35
+ try {
36
+ await reopenSetup();
37
+ window.location.href = "/admin/setup";
38
+ }
39
+ catch (err) {
40
+ setError(err instanceof ApiRequestError ? err.message : "Could not reopen setup.");
41
+ setReopening(false);
42
+ }
43
+ }
44
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Mailboxes" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: reopening, disabled: reopening, onClick: () => void handleRunSetup(), children: "Run setup again" }), _jsx("a", { href: "/admin/mailboxes/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New mailbox" }) })] })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(MailboxTable, { mailboxes: mailboxes })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
32
45
  }
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
3
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
4
+ import LoadedSettingsForm from "../../shared/components/admin/settings/LoadedSettingsForm.js";
5
+ import MailboxPolicyForm from "../../shared/components/admin/settings/MailboxPolicyForm.js";
6
+ export default function MailboxPolicyPage(props) {
7
+ return (_jsx(AdminShell, { ...props, active: "mailboxPolicy", children: _jsx(LoadedSettingsForm, { load: getMailboxPolicy, loadErrorMessage: "Could not load the mailbox policy.", children: (policy, onChange) => _jsx(MailboxPolicyForm, { policy: policy, onChange: onChange }) }) }));
8
+ }
@@ -1,93 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- ///////////////////////////////////////////////////////////////////////////////
3
- // Copyright (C) 2026 Jean-Philippe Steinmetz
4
- // SPDX-License-Identifier: MPL-2.0
5
- ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useState } from "react";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { createMailbox, listMailboxDomains } from "@rapidmx/react-shared/mail/mailApi.js";
9
2
  import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
10
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
13
- const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
- const SELECT_CLASS = "text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
3
+ import MailboxCreateForm from "../../../shared/components/admin/settings/MailboxCreateForm.js";
15
4
  export default function NewMailboxPage(props) {
16
- return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsx(NewMailboxForm, {}) }));
17
- }
18
- function NewMailboxForm() {
19
- const [primarySmtpAddress, setPrimarySmtpAddress] = useState("");
20
- const [localPart, setLocalPart] = useState("");
21
- const [domains, setDomains] = useState([]);
22
- const [domain, setDomain] = useState("");
23
- const [displayName, setDisplayName] = useState("");
24
- const [ownerUserUid, setOwnerUserUid] = useState("");
25
- const [timezone, setTimezone] = useState("UTC");
26
- const [quotaGb, setQuotaGb] = useState(5);
27
- const [isResource, setIsResource] = useState(false);
28
- const [resourceType, setResourceType] = useState("room");
29
- const [resourceCapacity, setResourceCapacity] = useState("");
30
- const [autoAcceptBookings, setAutoAcceptBookings] = useState(false);
31
- const [allowConflicts, setAllowConflicts] = useState(false);
32
- const [bookingWindowDays, setBookingWindowDays] = useState("");
33
- const [maxDurationMinutes, setMaxDurationMinutes] = useState("");
34
- const [error, setError] = useState(null);
35
- const [saving, setSaving] = useState(false);
36
- // An empty `mail:domains` list (the default) means this server enforces no domain restriction —
37
- // the address field stays free text, exactly as before. Once an admin configures at least one
38
- // domain, every mailbox this server creates must be on one of them (enforced server-side too, in
39
- // `BaseMailboxRoute.create()`), so the form switches to a local-part input + domain dropdown to
40
- // make that restriction visible rather than let an admin discover it only via a rejected submit.
41
- useEffect(() => {
42
- listMailboxDomains()
43
- .then((list) => {
44
- setDomains(list);
45
- setDomain(list[0] ?? "");
46
- })
47
- .catch(() => undefined);
48
- }, []);
49
- const constrained = domains.length > 0;
50
- async function handleSubmit(e) {
51
- e.preventDefault();
52
- setError(null);
53
- const address = constrained ? `${localPart.trim()}@${domain}` : primarySmtpAddress.trim();
54
- if (constrained ? !localPart.trim() : !primarySmtpAddress.trim()) {
55
- setError("A primary SMTP address is required.");
56
- return;
57
- }
58
- if (!displayName.trim()) {
59
- setError("A display name is required.");
60
- return;
61
- }
62
- setSaving(true);
63
- try {
64
- // ownerUserUid left blank creates a true ownerless shared mailbox (e.g. support@example.com);
65
- // delegates are then granted access from the mailbox's detail page. A resource mailbox is
66
- // ownerless the same way — leave "Owner user uid" blank for one too.
67
- const mailbox = await createMailbox({
68
- primarySmtpAddress: address,
69
- displayName: displayName.trim(),
70
- ownerUserUid: ownerUserUid.trim() || undefined,
71
- timezone,
72
- quotaBytes: Math.round(quotaGb * 1000000000),
73
- ...(isResource && {
74
- isResource: true,
75
- resourceType,
76
- resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
77
- autoAcceptBookings,
78
- allowConflicts,
79
- bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
80
- maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
81
- }),
82
- });
83
- window.location.href = `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`;
84
- }
85
- catch (err) {
86
- setError(err instanceof ApiRequestError ? err.message : "Could not create the mailbox.");
87
- }
88
- finally {
89
- setSaving(false);
90
- }
91
- }
92
- return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Leave \"Owner user uid\" blank to create a true ownerless shared mailbox (the Exchange \"shared mailbox\" concept) \u2014 access is then granted entirely to delegates afterward, not to a single owner. Set an owner to create a mailbox for a specific user instead." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [constrained ? (_jsx(FormField, { label: "Local part", htmlFor: "localPart", children: _jsxs("div", { className: "flex gap-2 items-center", children: [_jsx("input", { id: "localPart", type: "text", className: INPUT_CLASS, value: localPart, onChange: (e) => setLocalPart(e.target.value), placeholder: "support" }), _jsx("span", { className: "text-text-muted shrink-0", children: "@" }), _jsx("select", { "aria-label": "Domain", className: SELECT_CLASS, value: domain, onChange: (e) => setDomain(e.target.value), children: domains.map((d) => (_jsx("option", { value: d, children: d }, d))) })] }) })) : (_jsx(FormField, { label: "Primary SMTP address", htmlFor: "primarySmtpAddress", children: _jsx("input", { id: "primarySmtpAddress", type: "email", className: INPUT_CLASS, value: primarySmtpAddress, onChange: (e) => setPrimarySmtpAddress(e.target.value), placeholder: "support@example.com" }) })), _jsx(FormField, { label: "Display name", htmlFor: "displayName", children: _jsx("input", { id: "displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value), placeholder: "Support" }) }), _jsx(FormField, { label: "Owner user uid (optional)", htmlFor: "ownerUserUid", children: _jsx("input", { id: "ownerUserUid", type: "text", className: INPUT_CLASS, value: ownerUserUid, onChange: (e) => setOwnerUserUid(e.target.value), placeholder: "Leave blank for a shared mailbox" }) }), _jsx(FormField, { label: "Timezone", htmlFor: "timezone", children: _jsx("input", { id: "timezone", type: "text", className: INPUT_CLASS, value: timezone, onChange: (e) => setTimezone(e.target.value), placeholder: "America/Los_Angeles" }) }), _jsx(FormField, { label: "Quota (GB)", htmlFor: "quotaGb", children: _jsx("input", { id: "quotaGb", type: "number", min: 1, step: 1, className: INPUT_CLASS, value: quotaGb, onChange: (e) => setQuotaGb(Number(e.target.value)) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: isResource, onChange: (e) => setIsResource(e.target.checked) }), "This is a resource mailbox (a bookable room or piece of equipment)"] }), isResource && (_jsxs("div", { className: "border border-border rounded-sm p-4 mb-4 flex flex-col gap-4", children: [_jsx(FormField, { label: "Resource type", htmlFor: "resourceType", children: _jsxs("select", { id: "resourceType", className: SELECT_CLASS, value: resourceType, onChange: (e) => setResourceType(e.target.value), children: [_jsx("option", { value: "room", children: "Room" }), _jsx("option", { value: "equipment", children: "Equipment" })] }) }), _jsx(FormField, { label: "Capacity (optional)", htmlFor: "resourceCapacity", children: _jsx("input", { id: "resourceCapacity", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: resourceCapacity, onChange: (e) => setResourceCapacity(e.target.value), placeholder: "Informational only" }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: autoAcceptBookings, onChange: (e) => setAutoAcceptBookings(e.target.checked) }), "Automatically accept booking requests"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: allowConflicts, onChange: (e) => setAllowConflicts(e.target.checked) }), "Allow conflicting bookings (skip conflict checking entirely)"] }), _jsx(FormField, { label: "Booking window, in days (optional)", htmlFor: "bookingWindowDays", children: _jsx("input", { id: "bookingWindowDays", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: bookingWindowDays, onChange: (e) => setBookingWindowDays(e.target.value), placeholder: "No limit" }) }), _jsx(FormField, { label: "Maximum duration, in minutes (optional)", htmlFor: "maxDurationMinutes", children: _jsx("input", { id: "maxDurationMinutes", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: maxDurationMinutes, onChange: (e) => setMaxDurationMinutes(e.target.value), placeholder: "No limit" }) })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create mailbox" }), _jsx("a", { href: "/admin", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
5
+ return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Leave \"Owner user uid\" blank to create a true ownerless shared mailbox (the Exchange \"shared mailbox\" concept) \u2014 access is then granted entirely to delegates afterward, not to a single owner. Set an owner to create a mailbox for a specific user instead." }), _jsx(MailboxCreateForm, { cancelHref: "/admin", onCreated: (mailbox) => {
6
+ window.location.href = `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`;
7
+ } })] }) }));
93
8
  }
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
3
+ import PluginsManager from "../../shared/components/admin/settings/PluginsManager.js";
4
+ export default function PluginsPage(props) {
5
+ return (_jsx(AdminShell, { ...props, active: "plugins", children: _jsx(PluginsManager, {}) }));
6
+ }
@@ -1,15 +1,14 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { MIN_AUDIT_LOG_RETENTION_DAYS, getRetentionPolicy, updateRetentionPolicy, } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
8
+ import { getRetentionPolicy } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
9
9
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
10
+ import RetentionPolicyForm from "../../shared/components/admin/settings/RetentionPolicyForm.js";
10
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
- const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
12
  export default function RetentionPolicyPage(props) {
14
13
  return (_jsx(AdminShell, { ...props, active: "retentionPolicy", children: _jsx(RetentionPolicyContent, {}) }));
15
14
  }
@@ -31,35 +30,3 @@ function RetentionPolicyContent() {
31
30
  }
32
31
  return _jsx(RetentionPolicyForm, { policy: policy, onChange: setPolicy });
33
32
  }
34
- function RetentionPolicyForm({ policy, onChange }) {
35
- const [messageRetentionDays, setMessageRetentionDays] = useState(policy.messageRetentionDays?.toString() ?? "");
36
- const [auditLogRetentionDays, setAuditLogRetentionDays] = useState(policy.auditLogRetentionDays?.toString() ?? "");
37
- const [error, setError] = useState(null);
38
- const [saving, setSaving] = useState(false);
39
- const [saved, setSaved] = useState(false);
40
- async function handleSubmit(e) {
41
- e.preventDefault();
42
- setError(null);
43
- setSaved(false);
44
- setSaving(true);
45
- try {
46
- const patch = {};
47
- if (messageRetentionDays.trim() !== "") {
48
- patch.messageRetentionDays = Number(messageRetentionDays);
49
- }
50
- if (auditLogRetentionDays.trim() !== "") {
51
- patch.auditLogRetentionDays = Number(auditLogRetentionDays);
52
- }
53
- const updated = await updateRetentionPolicy(patch);
54
- onChange(updated);
55
- setSaved(true);
56
- }
57
- catch (err) {
58
- setError(err instanceof ApiRequestError ? err.message : "Could not save the retention policy.");
59
- }
60
- finally {
61
- setSaving(false);
62
- }
63
- }
64
- return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Retention Policy" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Automatically and permanently deletes mail/audit-log entries older than the configured age. Leave a field blank for no automatic purge. This does not apply to a mailbox that is a custodian on an active legal hold \u2014 a held message is skipped and retried on a later run once the hold is lifted." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Message retention (days)" }), _jsx("input", { "aria-label": "Message retention (days)", type: "number", min: 1, className: INPUT_CLASS, value: messageRetentionDays, onChange: (e) => setMessageRetentionDays(e.target.value), placeholder: "No automatic purge" }), _jsx("span", { className: "text-xs text-text-muted", children: "Applies to any message, in any folder, regardless of age." })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Audit log retention (days)" }), _jsx("input", { "aria-label": "Audit log retention (days)", type: "number", min: MIN_AUDIT_LOG_RETENTION_DAYS, className: INPUT_CLASS, value: auditLogRetentionDays, onChange: (e) => setAuditLogRetentionDays(e.target.value), placeholder: "Keep forever" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Cannot be set below ", MIN_AUDIT_LOG_RETENTION_DAYS, " days. Never applies to the tamper-evident escrow audit chain."] })] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
65
- }
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
3
+ import SetupWizard from "../../shared/components/admin/setup/SetupWizard.js";
4
+ export default function SetupPage(props) {
5
+ return (_jsx(AdminShell, { ...props, active: "setup", children: _jsx(SetupWizard, { userUid: props.userUid ?? "" }) }));
6
+ }
@@ -5,8 +5,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import "../../../styles/app.css";
7
7
  import { useEffect, useState } from "react";
8
- import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineDocumentArrowDown, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlinePaintBrush, HiOutlineQueueList, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, } from "react-icons/hi2";
8
+ import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineDocumentArrowDown, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, } from "react-icons/hi2";
9
9
  import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
+ import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
10
11
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
11
12
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
12
13
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
@@ -39,6 +40,18 @@ const NAV_ITEMS = [
39
40
  label: "Escrow Scopes",
40
41
  icon: HiOutlineKey,
41
42
  },
43
+ {
44
+ id: "encryptionPolicy",
45
+ href: "/admin/encryption-policy",
46
+ label: "Encryption Policy",
47
+ icon: HiOutlineLockClosed,
48
+ },
49
+ {
50
+ id: "mailboxPolicy",
51
+ href: "/admin/mailbox-policy",
52
+ label: "Mailbox Policy",
53
+ icon: HiOutlineWrenchScrewdriver,
54
+ },
42
55
  {
43
56
  id: "retentionPolicy",
44
57
  href: "/admin/retention-policy",
@@ -51,6 +64,7 @@ const NAV_ITEMS = [
51
64
  label: "Data Requests",
52
65
  icon: HiOutlineDocumentArrowDown,
53
66
  },
67
+ { id: "plugins", href: "/admin/plugins", label: "Plugins", icon: HiOutlinePuzzlePiece },
54
68
  { id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
55
69
  ];
56
70
  /** Mailbox-scoped sections — not part of the global nav rail/tab bar (see `NAV_ITEMS` above), but
@@ -59,7 +73,9 @@ const MAILBOX_SCOPED_ITEMS = [
59
73
  { id: "quarantine", href: "/admin/quarantine", label: "Quarantine", icon: HiOutlineShieldExclamation },
60
74
  { id: "ingestQueue", href: "/admin/ingest-queue", label: "Ingest Queue", icon: HiOutlineQueueList },
61
75
  ];
62
- const ALL_ITEMS = [...NAV_ITEMS, ...MAILBOX_SCOPED_ITEMS];
76
+ /** The setup wizard - reached by redirect or from the Mailboxes page, not from the rail. */
77
+ const SETUP_ITEM = { id: "setup", href: "/admin/setup", label: "Setup", icon: HiOutlineRocketLaunch };
78
+ const ALL_ITEMS = [...NAV_ITEMS, ...MAILBOX_SCOPED_ITEMS, SETUP_ITEM];
63
79
  /**
64
80
  * Gates every `apps/admin` page behind the `admin` trusted role. Uses `GET /api/admin/release-notes` (any
65
81
  * `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a canary: a 200 means the
@@ -76,7 +92,22 @@ export default function AdminShell({ active, userUid, authServerUrl, children })
76
92
  return;
77
93
  }
78
94
  apiFetch("/admin/release-notes")
79
- .then(() => setStatus("authorized"))
95
+ .then(async () => {
96
+ // Until first-run setup is finished, every other admin page sends the admin to the wizard. A failed
97
+ // check never blocks the console - the admin can still reach setup from the Mailboxes page.
98
+ if (active !== "setup") {
99
+ try {
100
+ if ((await getSetupStatus()).required) {
101
+ window.location.href = SETUP_ITEM.href;
102
+ return;
103
+ }
104
+ }
105
+ catch {
106
+ // Fall through to the page.
107
+ }
108
+ }
109
+ setStatus("authorized");
110
+ })
80
111
  .catch((err) => {
81
112
  if (err instanceof ApiRequestError && (err.status === 403 || err.status === 401)) {
82
113
  setStatus("denied");