@rapidmx/web-client 0.18.0 → 0.20.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 (131) hide show
  1. package/README.md +381 -381
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -482
  4. package/apps/admin/domains/[uid].tsx +166 -166
  5. package/apps/admin/escrow-scopes/[uid].tsx +350 -350
  6. package/apps/admin/index.tsx +129 -129
  7. package/apps/admin/mailboxes/[uid].tsx +271 -271
  8. package/apps/admin/mailboxes/new/index.tsx +30 -30
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
  16. package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
  17. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
  18. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
  19. package/apps/shared/components/admin/layout/AdminShell.tsx +384 -384
  20. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -238
  21. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  22. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -194
  23. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  25. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +2093 -1545
  27. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  28. package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -446
  31. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  32. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  33. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  34. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -103
  40. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  41. package/apps/shared/components/layout/AppShell.tsx +461 -461
  42. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  44. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  45. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  46. package/apps/shared/components/layout/UserMenu.tsx +439 -439
  47. package/apps/shared/components/mail/ConversationList.tsx +332 -323
  48. package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -613
  49. package/apps/shared/components/mail/MailSelectionBar.tsx +240 -240
  50. package/apps/shared/components/mail/MenuButton.tsx +404 -404
  51. package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1757
  52. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  53. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  54. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1681
  55. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  57. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  58. package/apps/shared/components/mail/listPreferences.ts +3 -3
  59. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -258
  60. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -79
  61. package/apps/shared/components/mail/reading/messageExport.ts +59 -59
  62. package/apps/shared/components/mail/reading/printMessage.ts +99 -99
  63. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -443
  64. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  65. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  66. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  67. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  68. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  69. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  70. package/apps/shared/keyboard/dispatch.ts +124 -124
  71. package/apps/shared/keyboard/format.ts +89 -89
  72. package/apps/shared/keyboard/keymap.ts +114 -114
  73. package/apps/shared/keyboard/registry.ts +65 -65
  74. package/apps/shared/keyboard/targets.ts +79 -79
  75. package/apps/shared/mail/folderOfType.ts +49 -49
  76. package/apps/shared/mail/folderTree.ts +143 -143
  77. package/apps/shared/mail/listAllPages.ts +39 -39
  78. package/apps/shared/mail/newMailNotifications.ts +171 -171
  79. package/apps/shared/mail/outbox/sendJob.ts +445 -445
  80. package/apps/shared/mail/outbox/sendOutcomes.ts +155 -155
  81. package/apps/shared/mail/reportNotices.ts +66 -66
  82. package/apps/shared/mail/senderBlocking.ts +141 -141
  83. package/apps/shared/mail/useMailConnection.ts +205 -205
  84. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  85. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -60
  86. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  87. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  88. package/apps/shared/notifications/store.ts +560 -560
  89. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  90. package/apps/shared/search/localIndexBuilder.ts +481 -481
  91. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  92. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  93. package/apps/shared/signing/enrollmentView.ts +251 -251
  94. package/apps/shared/signing/useNow.ts +19 -19
  95. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  96. package/apps/shared/styles/app.css +396 -396
  97. package/apps/www/calendar/index.tsx +581 -581
  98. package/apps/www/contacts/[uid].tsx +112 -112
  99. package/apps/www/index.tsx +3012 -2962
  100. package/apps/www/messages/[uid].tsx +139 -139
  101. package/apps/www/settings/auto-reply/index.tsx +136 -136
  102. package/apps/www/settings/blocked-senders/index.tsx +303 -303
  103. package/apps/www/settings/encryption/index.tsx +1290 -1290
  104. package/apps/www/settings/filters/[uid].tsx +179 -179
  105. package/apps/www/settings/filters/index.tsx +105 -105
  106. package/apps/www/settings/filters/new/index.tsx +165 -165
  107. package/apps/www/settings/labels/index.tsx +207 -207
  108. package/apps/www/settings/privacy/index.tsx +495 -495
  109. package/apps/www/settings/profile/index.tsx +251 -251
  110. package/apps/www/settings/read-receipts/index.tsx +150 -150
  111. package/apps/www/settings/sharing/index.tsx +259 -259
  112. package/apps/www/settings/signatures/[uid].tsx +175 -175
  113. package/apps/www/settings/signatures/index.tsx +91 -91
  114. package/apps/www/settings/signatures/new/index.tsx +138 -138
  115. package/apps/www/tasks/index.tsx +654 -654
  116. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
  117. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
  118. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
  119. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
  120. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
  121. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  122. package/dist/apps/shared/components/admin/settings/PluginsManager.js +328 -48
  123. package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
  124. package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
  125. package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
  126. package/dist/apps/shared/components/mail/ConversationList.js +9 -5
  127. package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/reading/printMessage.js +11 -11
  129. package/dist/apps/shared/styles/app.css +396 -396
  130. package/dist/apps/www/index.js +51 -10
  131. package/package.json +2 -2
@@ -1,271 +1,271 @@
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 { useRouter } from "@rapidrest/react/client";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { deleteMailbox, getMailbox, impersonateUser, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
9
- import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
10
- import ShareAccessCard from "../../shared/components/admin/mailboxes/ShareAccessCard.js";
11
- import ResourceSettingsCard from "../../shared/components/admin/mailboxes/ResourceSettingsCard.js";
12
- import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
13
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
15
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
16
-
17
- function formatBytes(bytes: number): string {
18
- if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
19
- if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
20
- if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
21
- return `${bytes} B`;
22
- }
23
-
24
- export default function MailboxDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
25
- return (
26
- <AdminShell {...props} active="mailboxes">
27
- <MailboxDetailContent uid={props.params.uid} impersonationBaseUrl={props.impersonationBaseUrl} currentUserUid={props.userUid} />
28
- </AdminShell>
29
- );
30
- }
31
-
32
- function MailboxDetailContent({
33
- uid,
34
- impersonationBaseUrl,
35
- currentUserUid,
36
- }: { uid: string; currentUserUid?: string } & Pick<AdminShellProps, "impersonationBaseUrl">) {
37
- const [mailbox, setMailbox] = useState<Mailbox | null>(null);
38
- const { navigate } = useRouter();
39
- const [loading, setLoading] = useState(true);
40
- const [error, setError] = useState<string | null>(null);
41
- const [confirmingAccess, setConfirmingAccess] = useState(false);
42
- const [impersonating, setImpersonating] = useState(false);
43
- const [accessError, setAccessError] = useState<string | null>(null);
44
- const [confirmingDelete, setConfirmingDelete] = useState(false);
45
- const [deleting, setDeleting] = useState(false);
46
- const [deleteError, setDeleteError] = useState<string | null>(null);
47
- // Deleting keeps the mailbox's data; this also erases it in the same step, after the address is typed to confirm.
48
- const [eraseData, setEraseData] = useState(false);
49
- const [typedAddress, setTypedAddress] = useState("");
50
-
51
- // Ignored while the action is under way (Escape/Close; Cancel is disabled then too), so its result or error
52
- // can't land on a modal reopened afterwards.
53
- function closeAccessModal() {
54
- if (impersonating) return;
55
- setConfirmingAccess(false);
56
- setAccessError(null);
57
- }
58
-
59
- // Only ever invoked from the impersonation-confirmation modal below, which only renders for a loaded mailbox that has
60
- // an owner. A failure stays in the modal rather than replacing the whole page.
61
- async function handleAccessMailbox() {
62
- setImpersonating(true);
63
- setAccessError(null);
64
- try {
65
- await impersonateUser(impersonationBaseUrl ?? "", mailbox!.ownerUserUid!);
66
- window.location.href = "/";
67
- } catch (err) {
68
- setAccessError(err instanceof ApiRequestError ? err.message : "Could not impersonate this user.");
69
- setImpersonating(false);
70
- }
71
- }
72
-
73
- function closeDeleteModal() {
74
- if (deleting) return;
75
- setConfirmingDelete(false);
76
- setEraseData(false);
77
- setTypedAddress("");
78
- setDeleteError(null);
79
- }
80
-
81
- // Only ever invoked from the delete-confirmation modal below, which itself only renders once
82
- // `mailbox` is resolved (the `error || !mailbox` branch below returns before this content mounts).
83
- async function handleDelete() {
84
- setDeleting(true);
85
- setDeleteError(null);
86
- try {
87
- await deleteMailbox(mailbox!.uid, mailbox!.version, { erase: eraseData });
88
- // The Mailboxes page it goes to lists what a delete without `erase` kept, and how far an erasure has got.
89
- void navigate("/admin");
90
- } catch (err) {
91
- // Most commonly a 409 if this mailbox is a custodian on an open legal hold (restapi's own
92
- // `assertNotOnLegalHold()`, naming the blocking Matter uid(s)) - surfaced as-is, same as
93
- // every other destructive-action error in this codebase, rather than special-cased here.
94
- setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this mailbox.");
95
- setDeleting(false);
96
- }
97
- }
98
-
99
- useEffect(() => {
100
- setLoading(true);
101
- setError(null);
102
- // The administration scope: administrative metadata only - the console never shows a mailbox's mail or settings.
103
- getMailbox(uid, { scope: "admin" })
104
- .then(setMailbox)
105
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox."))
106
- .finally(() => setLoading(false));
107
- }, [uid]);
108
-
109
- if (loading) {
110
- return <p className="text-sm text-text-muted">Loading&hellip;</p>;
111
- }
112
- if (error || !mailbox) {
113
- return <Alert>{error ?? "Mailbox not found."}</Alert>;
114
- }
115
-
116
- const quarantineHref = `/admin/quarantine?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
117
- const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
118
-
119
- return (
120
- <div className="max-w-3xl flex flex-col gap-5">
121
- <div className="flex items-start justify-between gap-4">
122
- <div>
123
- <a href="/admin" className="text-sm text-primary-dark hover:underline">
124
- &larr; All mailboxes
125
- </a>
126
- <h1 className="text-xl font-bold tracking-tight mt-1">{mailbox.primarySmtpAddress}</h1>
127
- </div>
128
- <div className="flex gap-3 shrink-0">
129
- {mailbox.ownerUserUid && (
130
- <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingAccess(true)}>
131
- Impersonate this user
132
- </Button>
133
- )}
134
- <Button
135
- type="button"
136
- variant="secondary"
137
- className="!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger"
138
- onClick={() => setConfirmingDelete(true)}
139
- >
140
- Delete mailbox
141
- </Button>
142
- </div>
143
- </div>
144
-
145
- <p className="text-sm text-text-muted">
146
- The console shows administrative details only - never a mailbox&rsquo;s mail or settings.
147
- {mailbox.ownerUserUid
148
- ? " To see this mailbox as its owner sees it, impersonate them: that is recorded, and you can stop at any time."
149
- : " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."}
150
- </p>
151
-
152
- <div className="bg-surface border border-border rounded-md p-6">
153
- <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
154
- <dt className="text-text-muted">Display name</dt>
155
- <dd>{mailbox.displayName}</dd>
156
- <dt className="text-text-muted">Owner</dt>
157
- <dd>{mailbox.ownerUserUid ?? "None (shared mailbox)"}</dd>
158
- <dt className="text-text-muted">Timezone</dt>
159
- <dd>{mailbox.timezone}</dd>
160
- <dt className="text-text-muted">Quota</dt>
161
- <dd>
162
- {formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
163
- </dd>
164
- <dt className="text-text-muted">Alias addresses</dt>
165
- <dd>{mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None"}</dd>
166
- {mailbox.isResource && (
167
- <>
168
- <dt className="text-text-muted">Resource type</dt>
169
- <dd className="capitalize">{mailbox.resourceType ?? "room"}</dd>
170
- </>
171
- )}
172
- <dt className="text-text-muted">Created</dt>
173
- <dd>{new Date(mailbox.dateCreated).toLocaleString()}</dd>
174
- </dl>
175
- <div className="flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium">
176
- <a href={quarantineHref} className="text-primary-dark hover:underline">
177
- View quarantine
178
- </a>
179
- <a href={ingestQueueHref} className="text-primary-dark hover:underline">
180
- View ingest queue
181
- </a>
182
- </div>
183
- </div>
184
-
185
- <ShareAccessCard mailboxUid={mailbox.uid} ownerUserUid={mailbox.ownerUserUid} currentUserUid={currentUserUid} />
186
-
187
- <EscrowScopeCard mailbox={mailbox} onUpdate={setMailbox} />
188
-
189
- {mailbox.isResource && <ResourceSettingsCard mailbox={mailbox} onUpdate={setMailbox} />}
190
-
191
- <Modal open={confirmingAccess} onClose={closeAccessModal} title="Impersonate this user">
192
- <p className="text-sm mb-3">
193
- You&rsquo;ll be signed in as <strong className="break-all">{mailbox.ownerUserUid}</strong>, the owner
194
- of <strong className="break-all">{mailbox.primarySmtpAddress}</strong>, and see everything they can -
195
- and act as them, until you stop impersonating. This is the only way an administrator sees another
196
- user&rsquo;s mail.
197
- </p>
198
- {accessError && <Alert>{accessError}</Alert>}
199
- <div className="flex gap-3 justify-end mt-5">
200
- <Button type="button" variant="secondary" className="!w-auto" disabled={impersonating} onClick={closeAccessModal}>
201
- Cancel
202
- </Button>
203
- <Button type="button" className="!w-auto" loading={impersonating} disabled={impersonating} onClick={handleAccessMailbox}>
204
- Impersonate
205
- </Button>
206
- </div>
207
- </Modal>
208
-
209
- <Modal open={confirmingDelete} onClose={closeDeleteModal} title="Delete mailbox">
210
- <p className="text-sm mb-3">
211
- Are you sure you want to delete <strong className="break-all">{mailbox.primarySmtpAddress}</strong>?
212
- This cannot be undone. It fails if this mailbox is a custodian on an active legal hold.
213
- </p>
214
- <p className="text-sm mb-3">
215
- Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are{" "}
216
- <strong>kept</strong> until they are erased, and until then the address can&rsquo;t be used for a new
217
- mailbox. You can erase them later from the Mailboxes page.
218
- </p>
219
- <label className="flex items-start gap-2 text-sm mb-3">
220
- <input
221
- type="checkbox"
222
- className="mt-0.5"
223
- checked={eraseData}
224
- disabled={deleting}
225
- onChange={(e) => {
226
- setEraseData(e.target.checked);
227
- setTypedAddress("");
228
- }}
229
- />
230
- <span>Also erase all of its data now (permanent)</span>
231
- </label>
232
- {eraseData && (
233
- <div className="mb-3">
234
- <p className="text-sm font-semibold text-danger mb-2">
235
- Everything in this mailbox will be permanently erased, and this cannot be undone.
236
- </p>
237
- <label className="flex flex-col gap-1.5 text-sm">
238
- <span className="font-semibold">
239
- Type <span className="break-all">{mailbox.primarySmtpAddress}</span> to confirm
240
- </span>
241
- <input
242
- aria-label="Type the address to confirm"
243
- className="w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
244
- value={typedAddress}
245
- disabled={deleting}
246
- autoComplete="off"
247
- spellCheck={false}
248
- onChange={(e) => setTypedAddress(e.target.value)}
249
- />
250
- </label>
251
- </div>
252
- )}
253
- {deleteError && <Alert>{deleteError}</Alert>}
254
- <div className="flex gap-3 justify-end mt-5">
255
- <Button type="button" variant="secondary" className="!w-auto" disabled={deleting} onClick={closeDeleteModal}>
256
- Cancel
257
- </Button>
258
- <Button
259
- type="button"
260
- className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
261
- loading={deleting}
262
- disabled={deleting || (eraseData && typedAddress.trim().toLowerCase() !== mailbox.primarySmtpAddress.trim().toLowerCase())}
263
- onClick={handleDelete}
264
- >
265
- {eraseData ? "Delete and erase all its data" : "Delete"}
266
- </Button>
267
- </div>
268
- </Modal>
269
- </div>
270
- );
271
- }
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 { useRouter } from "@rapidrest/react/client";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { deleteMailbox, getMailbox, impersonateUser, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
10
+ import ShareAccessCard from "../../shared/components/admin/mailboxes/ShareAccessCard.js";
11
+ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/ResourceSettingsCard.js";
12
+ import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
13
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
15
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
16
+
17
+ function formatBytes(bytes: number): string {
18
+ if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
19
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
20
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
21
+ return `${bytes} B`;
22
+ }
23
+
24
+ export default function MailboxDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
25
+ return (
26
+ <AdminShell {...props} active="mailboxes">
27
+ <MailboxDetailContent uid={props.params.uid} impersonationBaseUrl={props.impersonationBaseUrl} currentUserUid={props.userUid} />
28
+ </AdminShell>
29
+ );
30
+ }
31
+
32
+ function MailboxDetailContent({
33
+ uid,
34
+ impersonationBaseUrl,
35
+ currentUserUid,
36
+ }: { uid: string; currentUserUid?: string } & Pick<AdminShellProps, "impersonationBaseUrl">) {
37
+ const [mailbox, setMailbox] = useState<Mailbox | null>(null);
38
+ const { navigate } = useRouter();
39
+ const [loading, setLoading] = useState(true);
40
+ const [error, setError] = useState<string | null>(null);
41
+ const [confirmingAccess, setConfirmingAccess] = useState(false);
42
+ const [impersonating, setImpersonating] = useState(false);
43
+ const [accessError, setAccessError] = useState<string | null>(null);
44
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
45
+ const [deleting, setDeleting] = useState(false);
46
+ const [deleteError, setDeleteError] = useState<string | null>(null);
47
+ // Deleting keeps the mailbox's data; this also erases it in the same step, after the address is typed to confirm.
48
+ const [eraseData, setEraseData] = useState(false);
49
+ const [typedAddress, setTypedAddress] = useState("");
50
+
51
+ // Ignored while the action is under way (Escape/Close; Cancel is disabled then too), so its result or error
52
+ // can't land on a modal reopened afterwards.
53
+ function closeAccessModal() {
54
+ if (impersonating) return;
55
+ setConfirmingAccess(false);
56
+ setAccessError(null);
57
+ }
58
+
59
+ // Only ever invoked from the impersonation-confirmation modal below, which only renders for a loaded mailbox that has
60
+ // an owner. A failure stays in the modal rather than replacing the whole page.
61
+ async function handleAccessMailbox() {
62
+ setImpersonating(true);
63
+ setAccessError(null);
64
+ try {
65
+ await impersonateUser(impersonationBaseUrl ?? "", mailbox!.ownerUserUid!);
66
+ window.location.href = "/";
67
+ } catch (err) {
68
+ setAccessError(err instanceof ApiRequestError ? err.message : "Could not impersonate this user.");
69
+ setImpersonating(false);
70
+ }
71
+ }
72
+
73
+ function closeDeleteModal() {
74
+ if (deleting) return;
75
+ setConfirmingDelete(false);
76
+ setEraseData(false);
77
+ setTypedAddress("");
78
+ setDeleteError(null);
79
+ }
80
+
81
+ // Only ever invoked from the delete-confirmation modal below, which itself only renders once
82
+ // `mailbox` is resolved (the `error || !mailbox` branch below returns before this content mounts).
83
+ async function handleDelete() {
84
+ setDeleting(true);
85
+ setDeleteError(null);
86
+ try {
87
+ await deleteMailbox(mailbox!.uid, mailbox!.version, { erase: eraseData });
88
+ // The Mailboxes page it goes to lists what a delete without `erase` kept, and how far an erasure has got.
89
+ void navigate("/admin");
90
+ } catch (err) {
91
+ // Most commonly a 409 if this mailbox is a custodian on an open legal hold (restapi's own
92
+ // `assertNotOnLegalHold()`, naming the blocking Matter uid(s)) - surfaced as-is, same as
93
+ // every other destructive-action error in this codebase, rather than special-cased here.
94
+ setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this mailbox.");
95
+ setDeleting(false);
96
+ }
97
+ }
98
+
99
+ useEffect(() => {
100
+ setLoading(true);
101
+ setError(null);
102
+ // The administration scope: administrative metadata only - the console never shows a mailbox's mail or settings.
103
+ getMailbox(uid, { scope: "admin" })
104
+ .then(setMailbox)
105
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox."))
106
+ .finally(() => setLoading(false));
107
+ }, [uid]);
108
+
109
+ if (loading) {
110
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
111
+ }
112
+ if (error || !mailbox) {
113
+ return <Alert>{error ?? "Mailbox not found."}</Alert>;
114
+ }
115
+
116
+ const quarantineHref = `/admin/quarantine?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
117
+ const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
118
+
119
+ return (
120
+ <div className="max-w-3xl flex flex-col gap-5">
121
+ <div className="flex items-start justify-between gap-4">
122
+ <div>
123
+ <a href="/admin" className="text-sm text-primary-dark hover:underline">
124
+ &larr; All mailboxes
125
+ </a>
126
+ <h1 className="text-xl font-bold tracking-tight mt-1">{mailbox.primarySmtpAddress}</h1>
127
+ </div>
128
+ <div className="flex gap-3 shrink-0">
129
+ {mailbox.ownerUserUid && (
130
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingAccess(true)}>
131
+ Impersonate this user
132
+ </Button>
133
+ )}
134
+ <Button
135
+ type="button"
136
+ variant="secondary"
137
+ className="!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger"
138
+ onClick={() => setConfirmingDelete(true)}
139
+ >
140
+ Delete mailbox
141
+ </Button>
142
+ </div>
143
+ </div>
144
+
145
+ <p className="text-sm text-text-muted">
146
+ The console shows administrative details only - never a mailbox&rsquo;s mail or settings.
147
+ {mailbox.ownerUserUid
148
+ ? " To see this mailbox as its owner sees it, impersonate them: that is recorded, and you can stop at any time."
149
+ : " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."}
150
+ </p>
151
+
152
+ <div className="bg-surface border border-border rounded-md p-6">
153
+ <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
154
+ <dt className="text-text-muted">Display name</dt>
155
+ <dd>{mailbox.displayName}</dd>
156
+ <dt className="text-text-muted">Owner</dt>
157
+ <dd>{mailbox.ownerUserUid ?? "None (shared mailbox)"}</dd>
158
+ <dt className="text-text-muted">Timezone</dt>
159
+ <dd>{mailbox.timezone}</dd>
160
+ <dt className="text-text-muted">Quota</dt>
161
+ <dd>
162
+ {formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
163
+ </dd>
164
+ <dt className="text-text-muted">Alias addresses</dt>
165
+ <dd>{mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None"}</dd>
166
+ {mailbox.isResource && (
167
+ <>
168
+ <dt className="text-text-muted">Resource type</dt>
169
+ <dd className="capitalize">{mailbox.resourceType ?? "room"}</dd>
170
+ </>
171
+ )}
172
+ <dt className="text-text-muted">Created</dt>
173
+ <dd>{new Date(mailbox.dateCreated).toLocaleString()}</dd>
174
+ </dl>
175
+ <div className="flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium">
176
+ <a href={quarantineHref} className="text-primary-dark hover:underline">
177
+ View quarantine
178
+ </a>
179
+ <a href={ingestQueueHref} className="text-primary-dark hover:underline">
180
+ View ingest queue
181
+ </a>
182
+ </div>
183
+ </div>
184
+
185
+ <ShareAccessCard mailboxUid={mailbox.uid} ownerUserUid={mailbox.ownerUserUid} currentUserUid={currentUserUid} />
186
+
187
+ <EscrowScopeCard mailbox={mailbox} onUpdate={setMailbox} />
188
+
189
+ {mailbox.isResource && <ResourceSettingsCard mailbox={mailbox} onUpdate={setMailbox} />}
190
+
191
+ <Modal open={confirmingAccess} onClose={closeAccessModal} title="Impersonate this user">
192
+ <p className="text-sm mb-3">
193
+ You&rsquo;ll be signed in as <strong className="break-all">{mailbox.ownerUserUid}</strong>, the owner
194
+ of <strong className="break-all">{mailbox.primarySmtpAddress}</strong>, and see everything they can -
195
+ and act as them, until you stop impersonating. This is the only way an administrator sees another
196
+ user&rsquo;s mail.
197
+ </p>
198
+ {accessError && <Alert>{accessError}</Alert>}
199
+ <div className="flex gap-3 justify-end mt-5">
200
+ <Button type="button" variant="secondary" className="!w-auto" disabled={impersonating} onClick={closeAccessModal}>
201
+ Cancel
202
+ </Button>
203
+ <Button type="button" className="!w-auto" loading={impersonating} disabled={impersonating} onClick={handleAccessMailbox}>
204
+ Impersonate
205
+ </Button>
206
+ </div>
207
+ </Modal>
208
+
209
+ <Modal open={confirmingDelete} onClose={closeDeleteModal} title="Delete mailbox">
210
+ <p className="text-sm mb-3">
211
+ Are you sure you want to delete <strong className="break-all">{mailbox.primarySmtpAddress}</strong>?
212
+ This cannot be undone. It fails if this mailbox is a custodian on an active legal hold.
213
+ </p>
214
+ <p className="text-sm mb-3">
215
+ Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are{" "}
216
+ <strong>kept</strong> until they are erased, and until then the address can&rsquo;t be used for a new
217
+ mailbox. You can erase them later from the Mailboxes page.
218
+ </p>
219
+ <label className="flex items-start gap-2 text-sm mb-3">
220
+ <input
221
+ type="checkbox"
222
+ className="mt-0.5"
223
+ checked={eraseData}
224
+ disabled={deleting}
225
+ onChange={(e) => {
226
+ setEraseData(e.target.checked);
227
+ setTypedAddress("");
228
+ }}
229
+ />
230
+ <span>Also erase all of its data now (permanent)</span>
231
+ </label>
232
+ {eraseData && (
233
+ <div className="mb-3">
234
+ <p className="text-sm font-semibold text-danger mb-2">
235
+ Everything in this mailbox will be permanently erased, and this cannot be undone.
236
+ </p>
237
+ <label className="flex flex-col gap-1.5 text-sm">
238
+ <span className="font-semibold">
239
+ Type <span className="break-all">{mailbox.primarySmtpAddress}</span> to confirm
240
+ </span>
241
+ <input
242
+ aria-label="Type the address to confirm"
243
+ className="w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
244
+ value={typedAddress}
245
+ disabled={deleting}
246
+ autoComplete="off"
247
+ spellCheck={false}
248
+ onChange={(e) => setTypedAddress(e.target.value)}
249
+ />
250
+ </label>
251
+ </div>
252
+ )}
253
+ {deleteError && <Alert>{deleteError}</Alert>}
254
+ <div className="flex gap-3 justify-end mt-5">
255
+ <Button type="button" variant="secondary" className="!w-auto" disabled={deleting} onClick={closeDeleteModal}>
256
+ Cancel
257
+ </Button>
258
+ <Button
259
+ type="button"
260
+ className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
261
+ loading={deleting}
262
+ disabled={deleting || (eraseData && typedAddress.trim().toLowerCase() !== mailbox.primarySmtpAddress.trim().toLowerCase())}
263
+ onClick={handleDelete}
264
+ >
265
+ {eraseData ? "Delete and erase all its data" : "Delete"}
266
+ </Button>
267
+ </div>
268
+ </Modal>
269
+ </div>
270
+ );
271
+ }
@@ -1,30 +1,30 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React from "react";
6
- import { useRouter } from "@rapidrest/react/client";
7
- import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
8
- import MailboxCreateForm from "../../../shared/components/admin/settings/MailboxCreateForm.js";
9
-
10
- export default function NewMailboxPage(props: Omit<AdminShellProps, "active">) {
11
- const { navigate } = useRouter();
12
- return (
13
- <AdminShell {...props} active="mailboxes">
14
- <div className="max-w-xl">
15
- <h1 className="text-xl font-bold uppercase tracking-wide mb-1">New mailbox</h1>
16
- <p className="text-sm text-text-muted mb-5">
17
- Choose "Shared mailbox" to create a true ownerless mailbox (the Exchange "shared mailbox"
18
- concept) — access is then granted entirely to delegates afterward, not to a single owner. Choose
19
- "Owned by a specific person" and look them up to create a mailbox for a specific user instead.
20
- </p>
21
- <MailboxCreateForm
22
- cancelHref="/admin"
23
- onCreated={(mailbox) => {
24
- void navigate(`/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`);
25
- }}
26
- />
27
- </div>
28
- </AdminShell>
29
- );
30
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
7
+ import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
8
+ import MailboxCreateForm from "../../../shared/components/admin/settings/MailboxCreateForm.js";
9
+
10
+ export default function NewMailboxPage(props: Omit<AdminShellProps, "active">) {
11
+ const { navigate } = useRouter();
12
+ return (
13
+ <AdminShell {...props} active="mailboxes">
14
+ <div className="max-w-xl">
15
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">New mailbox</h1>
16
+ <p className="text-sm text-text-muted mb-5">
17
+ Choose "Shared mailbox" to create a true ownerless mailbox (the Exchange "shared mailbox"
18
+ concept) — access is then granted entirely to delegates afterward, not to a single owner. Choose
19
+ "Owned by a specific person" and look them up to create a mailbox for a specific user instead.
20
+ </p>
21
+ <MailboxCreateForm
22
+ cancelHref="/admin"
23
+ onCreated={(mailbox) => {
24
+ void navigate(`/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`);
25
+ }}
26
+ />
27
+ </div>
28
+ </AdminShell>
29
+ );
30
+ }