@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,259 +1,92 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import { deleteDomain, DnsRecordCheck, Domain, getDnsSetup, getDomain, verifyDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
- import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
12
-
13
- const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
14
- ownership: "Ownership (TXT)",
15
- mx: "MX",
16
- spf: "SPF",
17
- dkim: "DKIM",
18
- dmarc: "DMARC",
19
- };
20
-
21
- export default function DomainDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
22
- return (
23
- <AdminShell {...props} active="domains">
24
- <DomainDetailContent uid={props.params.uid} />
25
- </AdminShell>
26
- );
27
- }
28
-
29
- function DomainDetailContent({ uid }: { uid: string }) {
30
- const [domain, setDomain] = useState<Domain | null>(null);
31
- const [dnsSetup, setDnsSetup] = useState<DnsRecordCheck[]>([]);
32
- const [loading, setLoading] = useState(true);
33
- const [error, setError] = useState<string | null>(null);
34
- const [verifying, setVerifying] = useState(false);
35
- const [copied, setCopied] = useState(false);
36
- const [confirmingDelete, setConfirmingDelete] = useState(false);
37
- const [deleting, setDeleting] = useState(false);
38
- const [deleteError, setDeleteError] = useState<string | null>(null);
39
-
40
- function reload(id: string) {
41
- setLoading(true);
42
- setError(null);
43
- Promise.all([getDomain(id), getDnsSetup(id)])
44
- .then(([d, checks]) => {
45
- setDomain(d);
46
- setDnsSetup(checks);
47
- })
48
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this domain."))
49
- .finally(() => setLoading(false));
50
- }
51
-
52
- useEffect(() => {
53
- reload(uid);
54
- }, [uid]);
55
-
56
- // Only ever invoked from the "Verify now" button below, which itself only renders once `domain` is
57
- // resolved (children only render once loaded — see the early returns above) — the non-null assertion
58
- // reflects that real invariant, not an unchecked assumption. Matches `QuarantineContent.handleRelease`'s
59
- // identical pattern.
60
- async function handleVerify() {
61
- setVerifying(true);
62
- setError(null);
63
- try {
64
- await verifyDomain(domain!.uid);
65
- reload(domain!.uid);
66
- } catch (err) {
67
- setError(err instanceof ApiRequestError ? err.message : "Could not verify this domain.");
68
- } finally {
69
- setVerifying(false);
70
- }
71
- }
72
-
73
- // Only ever invoked from the "Delete domain" confirmation modal below, which itself only renders once
74
- // `domain` is resolved (children only render once loaded — see the early returns above) — the non-null
75
- // assertion reflects that real invariant, not an unchecked assumption. Matches `handleVerify`'s identical
76
- // pattern above.
77
- function closeDeleteModal() {
78
- setConfirmingDelete(false);
79
- }
80
-
81
- async function handleDelete() {
82
- setDeleting(true);
83
- setDeleteError(null);
84
- try {
85
- await deleteDomain(domain!.uid, domain!.version);
86
- window.location.href = "/admin/domains";
87
- } catch (err) {
88
- setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this domain.");
89
- setDeleting(false);
90
- }
91
- }
92
-
93
- async function handleCopy(value: string) {
94
- try {
95
- await navigator.clipboard.writeText(value);
96
- setCopied(true);
97
- setTimeout(() => setCopied(false), 2000);
98
- } catch {
99
- // Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
100
- }
101
- }
102
-
103
- if (loading) {
104
- return <p className="text-sm text-text-muted">Loading&hellip;</p>;
105
- }
106
- if (error || !domain) {
107
- return <Alert>{error ?? "Domain not found."}</Alert>;
108
- }
109
-
110
- const ownershipCheck = dnsSetup.find((c) => c.type === "ownership");
111
-
112
- return (
113
- <div className="max-w-3xl flex flex-col gap-5">
114
- <div className="flex items-start justify-between gap-4">
115
- <div>
116
- <a href="/admin/domains" className="text-sm text-primary-dark hover:underline">
117
- &larr; All domains
118
- </a>
119
- <h1 className="text-xl font-bold tracking-tight mt-1">{domain.name}</h1>
120
- </div>
121
- <Button
122
- type="button"
123
- variant="secondary"
124
- className="!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger"
125
- onClick={() => setConfirmingDelete(true)}
126
- >
127
- Delete domain
128
- </Button>
129
- </div>
130
-
131
- <div className="bg-surface border border-border rounded-md p-6">
132
- <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
133
- <dt className="text-text-muted">Enabled</dt>
134
- <dd>{domain.enabled ? "Yes" : "No"}</dd>
135
- <dt className="text-text-muted">Verification</dt>
136
- <dd>
137
- {domain.verified ? (
138
- <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white">
139
- Verified
140
- </span>
141
- ) : (
142
- <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
143
- Unverified
144
- </span>
145
- )}
146
- </dd>
147
- <dt className="text-text-muted">Last checked</dt>
148
- <dd>{domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never"}</dd>
149
- </dl>
150
-
151
- {!domain.verified && (
152
- <div className="mt-5 pt-5 border-t border-border">
153
- <p className="text-sm mb-2">
154
- Add the following TXT record to <strong>{domain.name}</strong> to prove ownership, then verify:
155
- </p>
156
- <div className="flex items-center gap-2">
157
- <code className="flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap">
158
- {ownershipCheck?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`}
159
- </code>
160
- <Button
161
- type="button"
162
- variant="secondary"
163
- className="!w-auto shrink-0"
164
- onClick={() =>
165
- handleCopy(
166
- ownershipCheck?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`,
167
- )
168
- }
169
- >
170
- {copied ? "Copied" : "Copy"}
171
- </Button>
172
- </div>
173
- <Button
174
- type="button"
175
- className="!w-auto mt-3"
176
- loading={verifying}
177
- disabled={verifying}
178
- onClick={handleVerify}
179
- >
180
- Verify now
181
- </Button>
182
- </div>
183
- )}
184
- </div>
185
-
186
- {dnsSetup.length > 0 && (
187
- <div className="bg-surface border border-border rounded-md p-6">
188
- <h2 className="text-sm font-bold uppercase tracking-wide mb-3">DNS setup checklist</h2>
189
- <div className="overflow-x-auto">
190
- <table className="w-full text-sm border-collapse">
191
- <thead>
192
- <tr>
193
- {["Record", "Status", "Recommended value"].map((h) => (
194
- <th
195
- key={h}
196
- className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
197
- >
198
- {h}
199
- </th>
200
- ))}
201
- </tr>
202
- </thead>
203
- <tbody>
204
- {dnsSetup.map((check) => (
205
- <tr key={check.type}>
206
- <td className="py-2.5 px-2.5 border-b border-border">{RECORD_TYPE_LABELS[check.type]}</td>
207
- <td className="py-2.5 px-2.5 border-b border-border">
208
- {!check.configured ? (
209
- <span className="text-xs text-text-muted">Not configured</span>
210
- ) : check.matches ? (
211
- <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white">
212
- Live
213
- </span>
214
- ) : (
215
- <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
216
- Not found
217
- </span>
218
- )}
219
- </td>
220
- <td className="py-2.5 px-2.5 border-b border-border text-text-muted">
221
- <code className="text-xs">{check.recommendedValue ?? "—"}</code>
222
- </td>
223
- </tr>
224
- ))}
225
- </tbody>
226
- </table>
227
- </div>
228
- </div>
229
- )}
230
-
231
- <Modal open={confirmingDelete} onClose={closeDeleteModal} title="Delete domain">
232
- <p className="text-sm mb-5">
233
- Are you sure you want to delete <strong>{domain.name}</strong>? This cannot be undone.
234
- </p>
235
- {deleteError && <Alert>{deleteError}</Alert>}
236
- <div className="flex gap-3 justify-end mt-5">
237
- <Button
238
- type="button"
239
- variant="secondary"
240
- className="!w-auto"
241
- disabled={deleting}
242
- onClick={closeDeleteModal}
243
- >
244
- Cancel
245
- </Button>
246
- <Button
247
- type="button"
248
- className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
249
- loading={deleting}
250
- disabled={deleting}
251
- onClick={handleDelete}
252
- >
253
- Delete
254
- </Button>
255
- </div>
256
- </Modal>
257
- </div>
258
- );
259
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { deleteDomain, Domain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
+ import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.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 Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
13
+
14
+ export default function DomainDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
15
+ return (
16
+ <AdminShell {...props} active="domains">
17
+ <DomainDetailContent uid={props.params.uid} />
18
+ </AdminShell>
19
+ );
20
+ }
21
+
22
+ function DomainDetailContent({ uid }: { uid: string }) {
23
+ const [domain, setDomain] = useState<Domain | null>(null);
24
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
25
+ const [deleting, setDeleting] = useState(false);
26
+ const [deleteError, setDeleteError] = useState<string | null>(null);
27
+
28
+ function closeDeleteModal() {
29
+ setConfirmingDelete(false);
30
+ }
31
+
32
+ // Only ever invoked from the "Delete domain" confirmation modal, which only renders once `domain` is loaded.
33
+ async function handleDelete() {
34
+ setDeleting(true);
35
+ setDeleteError(null);
36
+ try {
37
+ await deleteDomain(domain!.uid, domain!.version);
38
+ window.location.href = "/admin/domains";
39
+ } catch (err) {
40
+ setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this domain.");
41
+ setDeleting(false);
42
+ }
43
+ }
44
+
45
+ return (
46
+ <div className="max-w-3xl flex flex-col gap-5">
47
+ {domain && (
48
+ <div className="flex items-start justify-between gap-4">
49
+ <div>
50
+ <a href="/admin/domains" className="text-sm text-primary-dark hover:underline">
51
+ &larr; All domains
52
+ </a>
53
+ <h1 className="text-xl font-bold tracking-tight mt-1">{domain.name}</h1>
54
+ </div>
55
+ <Button
56
+ type="button"
57
+ variant="secondary"
58
+ className="!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger"
59
+ onClick={() => setConfirmingDelete(true)}
60
+ >
61
+ Delete domain
62
+ </Button>
63
+ </div>
64
+ )}
65
+
66
+ <DomainDnsSetup uid={uid} onLoaded={setDomain} />
67
+
68
+ {domain && (
69
+ <Modal open={confirmingDelete} onClose={closeDeleteModal} title="Delete domain">
70
+ <p className="text-sm mb-5">
71
+ Are you sure you want to delete <strong>{domain.name}</strong>? This cannot be undone.
72
+ </p>
73
+ {deleteError && <Alert>{deleteError}</Alert>}
74
+ <div className="flex gap-3 justify-end mt-5">
75
+ <Button type="button" variant="secondary" className="!w-auto" disabled={deleting} onClick={closeDeleteModal}>
76
+ Cancel
77
+ </Button>
78
+ <Button
79
+ type="button"
80
+ className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
81
+ loading={deleting}
82
+ disabled={deleting}
83
+ onClick={handleDelete}
84
+ >
85
+ Delete
86
+ </Button>
87
+ </div>
88
+ </Modal>
89
+ )}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,19 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { getEncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
7
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
8
+ import EncryptionPolicyForm from "../../shared/components/admin/settings/EncryptionPolicyForm.js";
9
+ import LoadedSettingsForm from "../../shared/components/admin/settings/LoadedSettingsForm.js";
10
+
11
+ export default function EncryptionPolicyPage(props: Omit<AdminShellProps, "active">) {
12
+ return (
13
+ <AdminShell {...props} active="encryptionPolicy">
14
+ <LoadedSettingsForm load={getEncryptionPolicy} loadErrorMessage="Could not load the encryption policy.">
15
+ {(policy, onChange) => <EncryptionPolicyForm policy={policy} onChange={onChange} />}
16
+ </LoadedSettingsForm>
17
+ </AdminShell>
18
+ );
19
+ }
@@ -1,82 +1,100 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import { listMailboxes, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
8
- import AdminShell, { AdminShellProps } from "../shared/components/admin/layout/AdminShell.js";
9
- import MailboxTable from "../shared/components/admin/mailboxes/MailboxTable.js";
10
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
-
13
- const PAGE_SIZE = 25;
14
-
15
- export default function MailboxesListPage(props: Omit<AdminShellProps, "active">) {
16
- return (
17
- <AdminShell {...props} active="mailboxes">
18
- <MailboxesListContent />
19
- </AdminShell>
20
- );
21
- }
22
-
23
- function MailboxesListContent() {
24
- const [page, setPage] = useState(0);
25
- const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
26
- const [loading, setLoading] = useState(true);
27
- const [error, setError] = useState<string | null>(null);
28
-
29
- useEffect(() => {
30
- setLoading(true);
31
- setError(null);
32
- listMailboxes({ page, limit: PAGE_SIZE })
33
- .then(setMailboxes)
34
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load mailboxes."))
35
- .finally(() => setLoading(false));
36
- }, [page]);
37
-
38
- const hasNextPage = mailboxes.length === PAGE_SIZE;
39
-
40
- return (
41
- <>
42
- <div className="flex items-center justify-between mb-5">
43
- <h1 className="text-xl font-bold uppercase tracking-wide">Mailboxes</h1>
44
- <a href="/admin/mailboxes/new">
45
- <Button type="button" className="!w-auto">
46
- + New mailbox
47
- </Button>
48
- </a>
49
- </div>
50
-
51
- {error && <Alert>{error}</Alert>}
52
-
53
- {loading ? (
54
- <p className="text-sm text-text-muted">Loading&hellip;</p>
55
- ) : (
56
- <MailboxTable mailboxes={mailboxes} />
57
- )}
58
-
59
- <div className="flex gap-3 items-center mt-4">
60
- <Button
61
- variant="secondary"
62
- type="button"
63
- className="!w-auto"
64
- disabled={page === 0 || loading}
65
- onClick={() => setPage((p) => Math.max(0, p - 1))}
66
- >
67
- Previous
68
- </Button>
69
- <span className="text-sm text-text-muted">Page {page + 1}</span>
70
- <Button
71
- variant="secondary"
72
- type="button"
73
- className="!w-auto"
74
- disabled={!hasNextPage || loading}
75
- onClick={() => setPage((p) => p + 1)}
76
- >
77
- Next
78
- </Button>
79
- </div>
80
- </>
81
- );
82
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { listMailboxes, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { reopenSetup } from "@rapidmx/react-shared/admin/setupApi.js";
9
+ import AdminShell, { AdminShellProps } from "../shared/components/admin/layout/AdminShell.js";
10
+ import MailboxTable from "../shared/components/admin/mailboxes/MailboxTable.js";
11
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
+
14
+ const PAGE_SIZE = 25;
15
+
16
+ export default function MailboxesListPage(props: Omit<AdminShellProps, "active">) {
17
+ return (
18
+ <AdminShell {...props} active="mailboxes">
19
+ <MailboxesListContent />
20
+ </AdminShell>
21
+ );
22
+ }
23
+
24
+ function MailboxesListContent() {
25
+ const [page, setPage] = useState(0);
26
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
27
+ const [loading, setLoading] = useState(true);
28
+ const [error, setError] = useState<string | null>(null);
29
+
30
+ useEffect(() => {
31
+ setLoading(true);
32
+ setError(null);
33
+ listMailboxes({ page, limit: PAGE_SIZE })
34
+ .then(setMailboxes)
35
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load mailboxes."))
36
+ .finally(() => setLoading(false));
37
+ }, [page]);
38
+
39
+ const hasNextPage = mailboxes.length === PAGE_SIZE;
40
+ const [reopening, setReopening] = useState(false);
41
+
42
+ async function handleRunSetup() {
43
+ setReopening(true);
44
+ try {
45
+ await reopenSetup();
46
+ window.location.href = "/admin/setup";
47
+ } catch (err) {
48
+ setError(err instanceof ApiRequestError ? err.message : "Could not reopen setup.");
49
+ setReopening(false);
50
+ }
51
+ }
52
+
53
+ return (
54
+ <>
55
+ <div className="flex items-center justify-between mb-5">
56
+ <h1 className="text-xl font-bold uppercase tracking-wide">Mailboxes</h1>
57
+ <div className="flex gap-2">
58
+ <Button type="button" variant="secondary" className="!w-auto" loading={reopening} disabled={reopening} onClick={() => void handleRunSetup()}>
59
+ Run setup again
60
+ </Button>
61
+ <a href="/admin/mailboxes/new">
62
+ <Button type="button" className="!w-auto">
63
+ + New mailbox
64
+ </Button>
65
+ </a>
66
+ </div>
67
+ </div>
68
+
69
+ {error && <Alert>{error}</Alert>}
70
+
71
+ {loading ? (
72
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
73
+ ) : (
74
+ <MailboxTable mailboxes={mailboxes} />
75
+ )}
76
+
77
+ <div className="flex gap-3 items-center mt-4">
78
+ <Button
79
+ variant="secondary"
80
+ type="button"
81
+ className="!w-auto"
82
+ disabled={page === 0 || loading}
83
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
84
+ >
85
+ Previous
86
+ </Button>
87
+ <span className="text-sm text-text-muted">Page {page + 1}</span>
88
+ <Button
89
+ variant="secondary"
90
+ type="button"
91
+ className="!w-auto"
92
+ disabled={!hasNextPage || loading}
93
+ onClick={() => setPage((p) => p + 1)}
94
+ >
95
+ Next
96
+ </Button>
97
+ </div>
98
+ </>
99
+ );
100
+ }
@@ -0,0 +1,19 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
7
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
8
+ import LoadedSettingsForm from "../../shared/components/admin/settings/LoadedSettingsForm.js";
9
+ import MailboxPolicyForm from "../../shared/components/admin/settings/MailboxPolicyForm.js";
10
+
11
+ export default function MailboxPolicyPage(props: Omit<AdminShellProps, "active">) {
12
+ return (
13
+ <AdminShell {...props} active="mailboxPolicy">
14
+ <LoadedSettingsForm load={getMailboxPolicy} loadErrorMessage="Could not load the mailbox policy.">
15
+ {(policy, onChange) => <MailboxPolicyForm policy={policy} onChange={onChange} />}
16
+ </LoadedSettingsForm>
17
+ </AdminShell>
18
+ );
19
+ }