@rapidmx/web-client 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- package/package.json +87 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 { getMailbox, impersonateUser } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import AdminShell 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 Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
13
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
14
|
+
function formatBytes(bytes) {
|
|
15
|
+
if (bytes >= 1000000000)
|
|
16
|
+
return `${(bytes / 1000000000).toFixed(2)} GB`;
|
|
17
|
+
if (bytes >= 1000000)
|
|
18
|
+
return `${(bytes / 1000000).toFixed(1)} MB`;
|
|
19
|
+
if (bytes >= 1000)
|
|
20
|
+
return `${(bytes / 1000).toFixed(1)} KB`;
|
|
21
|
+
return `${bytes} B`;
|
|
22
|
+
}
|
|
23
|
+
export default function MailboxDetailPage(props) {
|
|
24
|
+
return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsx(MailboxDetailContent, { uid: props.params.uid, impersonationBaseUrl: props.impersonationBaseUrl }) }));
|
|
25
|
+
}
|
|
26
|
+
function MailboxDetailContent({ uid, impersonationBaseUrl }) {
|
|
27
|
+
const [mailbox, setMailbox] = useState(null);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
const [error, setError] = useState(null);
|
|
30
|
+
const [impersonating, setImpersonating] = useState(false);
|
|
31
|
+
async function handleAccessMailbox(ownerUserUid) {
|
|
32
|
+
setImpersonating(true);
|
|
33
|
+
try {
|
|
34
|
+
await impersonateUser(impersonationBaseUrl ?? "", ownerUserUid);
|
|
35
|
+
window.location.href = "/";
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not access this mailbox.");
|
|
39
|
+
setImpersonating(false);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setLoading(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
getMailbox(uid)
|
|
46
|
+
.then(setMailbox)
|
|
47
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox."))
|
|
48
|
+
.finally(() => setLoading(false));
|
|
49
|
+
}, [uid]);
|
|
50
|
+
if (loading) {
|
|
51
|
+
return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
|
|
52
|
+
}
|
|
53
|
+
if (error || !mailbox) {
|
|
54
|
+
return _jsx(Alert, { children: error ?? "Mailbox not found." });
|
|
55
|
+
}
|
|
56
|
+
const quarantineHref = `/admin/quarantine?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
|
|
57
|
+
const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
|
|
58
|
+
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", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All mailboxes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: mailbox.primarySmtpAddress })] }), mailbox.ownerUserUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: impersonating, disabled: impersonating, onClick: () => handleAccessMailbox(mailbox.ownerUserUid), children: "Access this mailbox" }))] }), _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: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox })] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
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";
|
|
15
|
+
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" }) })] })] })] }));
|
|
93
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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 { listQuarantine, releaseQuarantineEntry } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
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
|
+
const PAGE_SIZE = 25;
|
|
13
|
+
export function readMailboxUid() {
|
|
14
|
+
if (typeof window === "undefined")
|
|
15
|
+
return null;
|
|
16
|
+
return new URLSearchParams(window.location.search).get("mailboxUid");
|
|
17
|
+
}
|
|
18
|
+
export default function QuarantinePage(props) {
|
|
19
|
+
return (_jsx(AdminShell, { ...props, active: "quarantine", children: _jsx(QuarantineContent, { userUid: props.userUid }) }));
|
|
20
|
+
}
|
|
21
|
+
function QuarantineContent({ userUid }) {
|
|
22
|
+
const [mailboxUid, setMailboxUid] = useState(null);
|
|
23
|
+
const [page, setPage] = useState(0);
|
|
24
|
+
const [entries, setEntries] = useState([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState(null);
|
|
27
|
+
const [releasing, setReleasing] = useState(null);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setMailboxUid(readMailboxUid());
|
|
30
|
+
}, []);
|
|
31
|
+
function reload(uid) {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
listQuarantine(uid, { page, limit: PAGE_SIZE })
|
|
35
|
+
.then(setEntries)
|
|
36
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load quarantine."))
|
|
37
|
+
.finally(() => setLoading(false));
|
|
38
|
+
}
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (mailboxUid) {
|
|
41
|
+
reload(mailboxUid);
|
|
42
|
+
}
|
|
43
|
+
}, [mailboxUid, page]);
|
|
44
|
+
const hasNextPage = entries.length === PAGE_SIZE;
|
|
45
|
+
// Only ever invoked from the "Release" button below, which itself only renders once `mailboxUid` is
|
|
46
|
+
// known and `AdminShell` has already confirmed `userUid` (children only render once authorized) — both
|
|
47
|
+
// non-null assertions reflect that real invariant, not an unchecked assumption.
|
|
48
|
+
async function handleRelease(entry) {
|
|
49
|
+
setReleasing(entry.uid);
|
|
50
|
+
setError(null);
|
|
51
|
+
try {
|
|
52
|
+
await releaseQuarantineEntry(entry.uid, entry.version, userUid);
|
|
53
|
+
reload(mailboxUid);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not release this message.");
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
setReleasing(null);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!mailboxUid) {
|
|
63
|
+
return _jsx(Alert, { children: "No mailbox specified. Open a mailbox's detail page and choose \"View quarantine\"." });
|
|
64
|
+
}
|
|
65
|
+
return (_jsxs("div", { className: "max-w-4xl flex flex-col gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: `/admin/mailboxes/${encodeURIComponent(mailboxUid)}`, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to mailbox" }), _jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mt-1", children: "Quarantine" })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : entries.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "Nothing quarantined for this mailbox." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Reason", "Quarantined", "Status", ""].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: entries.map((entry) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.reason }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: new Date(entry.dateCreated).toLocaleString() }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.releasedAt ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Released" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Held" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: !entry.releasedAt && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: releasing === entry.uid, disabled: releasing === entry.uid, onClick: () => handleRelease(entry), children: "Release" })) })] }, entry.uid))) })] }) })), _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" })] })] }));
|
|
66
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { getTransportRule, updateTransportRule, } from "@rapidmx/react-shared/admin/transportRulesApi.js";
|
|
9
|
+
import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
|
|
10
|
+
import RuleBuilder from "../../shared/components/rules/RuleBuilder.js";
|
|
11
|
+
import { TRANSPORT_RULE_ACTION_TYPES, TRANSPORT_RULE_CONDITION_FIELDS } from "./_transportRuleConfig.js";
|
|
12
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
13
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
14
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
15
|
+
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";
|
|
16
|
+
export default function TransportRuleDetailPage(props) {
|
|
17
|
+
return (_jsx(AdminShell, { ...props, active: "transportRules", children: _jsx(TransportRuleDetailContent, { uid: props.params.uid }) }));
|
|
18
|
+
}
|
|
19
|
+
function TransportRuleDetailContent({ uid }) {
|
|
20
|
+
const [original, setOriginal] = useState(null);
|
|
21
|
+
const [name, setName] = useState("");
|
|
22
|
+
const [rule, setRule] = useState(null);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
const [saving, setSaving] = useState(false);
|
|
26
|
+
const [saved, setSaved] = useState(false);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setLoading(true);
|
|
29
|
+
setError(null);
|
|
30
|
+
getTransportRule(uid)
|
|
31
|
+
.then((loaded) => {
|
|
32
|
+
setOriginal(loaded);
|
|
33
|
+
if (loaded) {
|
|
34
|
+
setName(loaded.name);
|
|
35
|
+
setRule({
|
|
36
|
+
enabled: loaded.enabled,
|
|
37
|
+
sequence: loaded.sequence,
|
|
38
|
+
stopProcessingRules: loaded.stopProcessingRules,
|
|
39
|
+
conditions: loaded.conditions,
|
|
40
|
+
actions: loaded.actions,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this transport rule."))
|
|
45
|
+
.finally(() => setLoading(false));
|
|
46
|
+
}, [uid]);
|
|
47
|
+
// Only ever invoked from the form below, which itself only renders once `original`/`rule` are
|
|
48
|
+
// loaded (the early returns above cover every other state) — the non-null assertions reflect that
|
|
49
|
+
// real invariant, not an unchecked assumption. Matches `QuarantineContent.handleRelease`'s and
|
|
50
|
+
// `DomainDetailContent.handleVerify`'s identical pattern.
|
|
51
|
+
async function handleSubmit(e) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
setError(null);
|
|
54
|
+
if (!name.trim()) {
|
|
55
|
+
setError("A name is required.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setSaving(true);
|
|
59
|
+
setSaved(false);
|
|
60
|
+
try {
|
|
61
|
+
const updated = await updateTransportRule({
|
|
62
|
+
uid: original.uid,
|
|
63
|
+
version: original.version,
|
|
64
|
+
name: name.trim(),
|
|
65
|
+
...rule,
|
|
66
|
+
});
|
|
67
|
+
setOriginal(updated);
|
|
68
|
+
setSaved(true);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save this transport rule.");
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
setSaving(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (loading) {
|
|
78
|
+
return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
|
|
79
|
+
}
|
|
80
|
+
if (error && !original) {
|
|
81
|
+
return _jsx(Alert, { children: error });
|
|
82
|
+
}
|
|
83
|
+
if (!original || !rule) {
|
|
84
|
+
return _jsx(Alert, { children: "Transport rule not found." });
|
|
85
|
+
}
|
|
86
|
+
return (_jsxs("div", { className: "max-w-3xl", children: [_jsx("a", { href: "/admin/transport-rules", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All transport rules" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1 mb-5", children: original.name }), 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-5", children: [_jsx("div", { className: "bg-surface border border-border rounded-md p-6", children: _jsx(FormField, { label: "Name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value) }) }) }), _jsx(RuleBuilder, { value: rule, onChange: setRule, conditionFields: TRANSPORT_RULE_CONDITION_FIELDS, actionTypes: TRANSPORT_RULE_ACTION_TYPES }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save changes" }) })] })] }));
|
|
87
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
const FIELD_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";
|
|
3
|
+
export const TRANSPORT_RULE_CONDITION_FIELDS = [
|
|
4
|
+
{ key: "fromContains", label: "From contains", kind: "list" },
|
|
5
|
+
{ key: "subjectContains", label: "Subject contains", kind: "list" },
|
|
6
|
+
{ key: "bodyContains", label: "Body contains", kind: "list" },
|
|
7
|
+
{ key: "recipientContains", label: "Any recipient contains", kind: "list" },
|
|
8
|
+
{ key: "anyRecipientExternal", label: "Any recipient is external", kind: "boolean" },
|
|
9
|
+
{ key: "hasAttachment", label: "Has an attachment", kind: "boolean" },
|
|
10
|
+
{ key: "attachmentNameContains", label: "Attachment filename contains", kind: "list" },
|
|
11
|
+
];
|
|
12
|
+
export const TRANSPORT_RULE_ACTION_TYPES = [
|
|
13
|
+
{
|
|
14
|
+
value: "reject",
|
|
15
|
+
label: "Reject the message",
|
|
16
|
+
createDefault: () => ({ type: "reject" }),
|
|
17
|
+
render: () => null,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
value: "quarantine",
|
|
21
|
+
label: "Quarantine every recipient's copy",
|
|
22
|
+
createDefault: () => ({ type: "quarantine" }),
|
|
23
|
+
render: () => null,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
value: "add_header",
|
|
27
|
+
label: "Add a header",
|
|
28
|
+
createDefault: () => ({ type: "add_header", headerName: "", headerValue: "" }),
|
|
29
|
+
render: (action, onChange) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "text", "aria-label": "Header name", className: FIELD_INPUT_CLASS, placeholder: "X-Compliance-Flag", value: action.headerName ?? "", onChange: (e) => onChange({ ...action, headerName: e.target.value }) }), _jsx("input", { type: "text", "aria-label": "Header value", className: FIELD_INPUT_CLASS, placeholder: "reviewed", value: action.headerValue ?? "", onChange: (e) => onChange({ ...action, headerValue: e.target.value }) })] })),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
value: "add_recipient",
|
|
33
|
+
label: "Deliver an additional copy to",
|
|
34
|
+
createDefault: () => ({ type: "add_recipient", recipientAddress: "" }),
|
|
35
|
+
render: (action, onChange) => (_jsx("input", { type: "email", "aria-label": "Recipient address", className: FIELD_INPUT_CLASS, placeholder: "compliance@example.com", value: action.recipientAddress ?? "", onChange: (e) => onChange({ ...action, recipientAddress: e.target.value }) })),
|
|
36
|
+
},
|
|
37
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 { listTransportRules } from "@rapidmx/react-shared/admin/transportRulesApi.js";
|
|
9
|
+
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
|
+
const PAGE_SIZE = 25;
|
|
13
|
+
export default function TransportRulesPage(props) {
|
|
14
|
+
return (_jsx(AdminShell, { ...props, active: "transportRules", children: _jsx(TransportRulesContent, {}) }));
|
|
15
|
+
}
|
|
16
|
+
function TransportRulesContent() {
|
|
17
|
+
const [page, setPage] = useState(0);
|
|
18
|
+
const [rules, setRules] = useState([]);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
listTransportRules({ page, limit: PAGE_SIZE })
|
|
25
|
+
.then(setRules)
|
|
26
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load transport rules."))
|
|
27
|
+
.finally(() => setLoading(false));
|
|
28
|
+
}, [page]);
|
|
29
|
+
const hasNextPage = rules.length === PAGE_SIZE;
|
|
30
|
+
const sorted = [...rules].sort((a, b) => a.sequence - b.sequence);
|
|
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: "Transport rules" }), _jsx("a", { href: "/admin/transport-rules/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New transport rule" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No transport rules yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Sequence", "Name", "Enabled", "Actions", ""].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: sorted.map((rule) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: rule.sequence }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: rule.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: rule.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: rule.actions.length }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/transport-rules/${encodeURIComponent(rule.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, rule.uid))) })] }) })), _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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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 { useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { createTransportRule, } from "@rapidmx/react-shared/admin/transportRulesApi.js";
|
|
9
|
+
import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
|
|
10
|
+
import RuleBuilder from "../../../shared/components/rules/RuleBuilder.js";
|
|
11
|
+
import { TRANSPORT_RULE_ACTION_TYPES, TRANSPORT_RULE_CONDITION_FIELDS } from "../_transportRuleConfig.js";
|
|
12
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
13
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
14
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
15
|
+
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";
|
|
16
|
+
export default function NewTransportRulePage(props) {
|
|
17
|
+
return (_jsx(AdminShell, { ...props, active: "transportRules", children: _jsx(NewTransportRuleForm, {}) }));
|
|
18
|
+
}
|
|
19
|
+
function NewTransportRuleForm() {
|
|
20
|
+
const [name, setName] = useState("");
|
|
21
|
+
const [rule, setRule] = useState({
|
|
22
|
+
enabled: true,
|
|
23
|
+
sequence: 0,
|
|
24
|
+
stopProcessingRules: false,
|
|
25
|
+
conditions: {},
|
|
26
|
+
actions: [],
|
|
27
|
+
});
|
|
28
|
+
const [error, setError] = useState(null);
|
|
29
|
+
const [saving, setSaving] = useState(false);
|
|
30
|
+
async function handleSubmit(e) {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
setError(null);
|
|
33
|
+
if (!name.trim()) {
|
|
34
|
+
setError("A name is required.");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setSaving(true);
|
|
38
|
+
try {
|
|
39
|
+
const created = await createTransportRule({ name: name.trim(), ...rule });
|
|
40
|
+
window.location.href = `/admin/transport-rules/${encodeURIComponent(created.uid)}`;
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create the transport rule.");
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
setSaving(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return (_jsxs("div", { className: "max-w-3xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New transport rule" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Evaluated once per SMTP transaction, before any message is resolved to an individual mailbox." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-5", children: [_jsx("div", { className: "bg-surface border border-border rounded-md p-6", children: _jsx(FormField, { label: "Name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "Flag external senders" }) }) }), _jsx(RuleBuilder, { value: rule, onChange: setRule, conditionFields: TRANSPORT_RULE_CONDITION_FIELDS, actionTypes: TRANSPORT_RULE_ACTION_TYPES }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create transport rule" }), _jsx("a", { href: "/admin/transport-rules", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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 { useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { updateDistributionList } from "@rapidmx/react-shared/admin/distributionListsApi.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
/**
|
|
12
|
+
* Lets an admin view and edit a distribution list's members — same load/inline-add-row/per-row-remove
|
|
13
|
+
* interaction shape as `ShareAccessCard`, but against a plain `memberAddresses: string[]` field on the
|
|
14
|
+
* `DistributionList` itself via read-modify-write `PUT`, not a separate ACL endpoint (a distribution
|
|
15
|
+
* list has no per-record ACL to speak of — see `BaseDistributionListRoute`'s own doc comment).
|
|
16
|
+
*/
|
|
17
|
+
export default function MemberListCard({ list, onUpdate }) {
|
|
18
|
+
const [newAddress, setNewAddress] = useState("");
|
|
19
|
+
const [saving, setSaving] = useState(false);
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
async function handleAdd(e) {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
const address = newAddress.trim();
|
|
24
|
+
if (!address || list.memberAddresses.includes(address)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
setSaving(true);
|
|
28
|
+
setError(null);
|
|
29
|
+
try {
|
|
30
|
+
const updated = await updateDistributionList({
|
|
31
|
+
uid: list.uid,
|
|
32
|
+
version: list.version,
|
|
33
|
+
memberAddresses: [...list.memberAddresses, address],
|
|
34
|
+
});
|
|
35
|
+
setNewAddress("");
|
|
36
|
+
onUpdate(updated);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not add this member.");
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
setSaving(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function handleRemove(address) {
|
|
46
|
+
setSaving(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
const updated = await updateDistributionList({
|
|
50
|
+
uid: list.uid,
|
|
51
|
+
version: list.version,
|
|
52
|
+
memberAddresses: list.memberAddresses.filter((a) => a !== address),
|
|
53
|
+
});
|
|
54
|
+
onUpdate(updated);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not remove this member.");
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
setSaving(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Members" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Each address may resolve to an internal mailbox, a nested distribution list, or a genuinely external address relayed out." }), error && _jsx(Alert, { children: error }), _jsxs("ul", { className: "flex flex-col gap-2 mb-4", children: [list.memberAddresses.length === 0 && (_jsx("li", { className: "text-sm text-text-muted", children: "No members yet." })), list.memberAddresses.map((address) => (_jsxs("li", { className: "flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm", children: [_jsx("div", { className: "text-sm font-medium", children: address }), _jsx("button", { type: "button", onClick: () => handleRemove(address), disabled: saving, className: "text-sm text-danger hover:underline disabled:opacity-55", children: "Remove" })] }, address)))] }), _jsxs("form", { onSubmit: handleAdd, className: "flex gap-2", children: [_jsx("input", { type: "text", value: newAddress, onChange: (e) => setNewAddress(e.target.value), placeholder: "Member address to add", className: "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary" }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Add" })] })] }));
|
|
64
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import "../../../styles/app.css";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { HiOutlineClipboardDocumentList, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlinePaintBrush, HiOutlineQueueList, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, } from "react-icons/hi2";
|
|
9
|
+
import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
10
|
+
import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
|
|
11
|
+
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
12
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
13
|
+
import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
14
|
+
import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
|
|
15
|
+
import UserMenu from "../../layout/UserMenu.js";
|
|
16
|
+
/** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
|
|
17
|
+
* anywhere in the console. Deliberately excludes `quarantine`/`ingestQueue`: those are scoped to a
|
|
18
|
+
* single mailbox and only ever reached via links on that mailbox's own detail page (see
|
|
19
|
+
* `apps/admin/mailboxes/[uid].tsx`), not global navigation destinations. */
|
|
20
|
+
const NAV_ITEMS = [
|
|
21
|
+
{ id: "mailboxes", href: "/admin", label: "Mailboxes", icon: HiOutlineInboxStack },
|
|
22
|
+
{ id: "domains", href: "/admin/domains", label: "Domains", icon: HiOutlineGlobeAlt },
|
|
23
|
+
{ id: "auditLog", href: "/admin/audit-log", label: "Audit Log", icon: HiOutlineClipboardDocumentList },
|
|
24
|
+
{
|
|
25
|
+
id: "distributionLists",
|
|
26
|
+
href: "/admin/distribution-lists",
|
|
27
|
+
label: "Distribution Lists",
|
|
28
|
+
icon: HiOutlineUserGroup,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "transportRules",
|
|
32
|
+
href: "/admin/transport-rules",
|
|
33
|
+
label: "Transport Rules",
|
|
34
|
+
icon: HiOutlineShieldCheck,
|
|
35
|
+
},
|
|
36
|
+
{ id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
|
|
37
|
+
];
|
|
38
|
+
/** Mailbox-scoped sections — not part of the global nav rail/tab bar (see `NAV_ITEMS` above), but
|
|
39
|
+
* still need an entry here so the header can resolve a label for them when they're the active page. */
|
|
40
|
+
const MAILBOX_SCOPED_ITEMS = [
|
|
41
|
+
{ id: "quarantine", href: "/admin/quarantine", label: "Quarantine", icon: HiOutlineShieldExclamation },
|
|
42
|
+
{ id: "ingestQueue", href: "/admin/ingest-queue", label: "Ingest Queue", icon: HiOutlineQueueList },
|
|
43
|
+
];
|
|
44
|
+
const ALL_ITEMS = [...NAV_ITEMS, ...MAILBOX_SCOPED_ITEMS];
|
|
45
|
+
/**
|
|
46
|
+
* Gates every `apps/admin` page behind the `admin` trusted role. Uses `GET /api/admin/release-notes` (any
|
|
47
|
+
* `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a canary: a 200 means the
|
|
48
|
+
* caller's JWT carries a trusted role, a 403 means it doesn't. There is no local step-up/elevation flow
|
|
49
|
+
* (that would need a cross-origin call to auth-server's own elevation endpoint — not wired up yet).
|
|
50
|
+
*/
|
|
51
|
+
export default function AdminShell({ active, userUid, authServerUrl, children }) {
|
|
52
|
+
const [status, setStatus] = useState("checking");
|
|
53
|
+
const [error, setError] = useState(null);
|
|
54
|
+
const { branding, iconSrc } = useBranding();
|
|
55
|
+
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!userUid) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
apiFetch("/admin/release-notes")
|
|
61
|
+
.then(() => setStatus("authorized"))
|
|
62
|
+
.catch((err) => {
|
|
63
|
+
if (err instanceof ApiRequestError && (err.status === 403 || err.status === 401)) {
|
|
64
|
+
setStatus("denied");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not verify administrator access.");
|
|
68
|
+
setStatus("error");
|
|
69
|
+
});
|
|
70
|
+
}, [userUid]);
|
|
71
|
+
function handleSignOut() {
|
|
72
|
+
window.location.href = authServerUrl ?? "/";
|
|
73
|
+
}
|
|
74
|
+
let content;
|
|
75
|
+
if (!userUid || status === "checking") {
|
|
76
|
+
content = _jsx("div", { className: "min-h-screen" });
|
|
77
|
+
}
|
|
78
|
+
else if (status === "denied") {
|
|
79
|
+
content = (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: "You do not have administrator access." }) }) }));
|
|
80
|
+
}
|
|
81
|
+
else if (status === "error") {
|
|
82
|
+
content = (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const activeItem = ALL_ITEMS.find((item) => item.id === active);
|
|
86
|
+
content = (_jsx("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Admin sections", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), NAV_ITEMS.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
|
|
87
|
+
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
88
|
+
id === active
|
|
89
|
+
? "bg-primary/10 text-primary-dark"
|
|
90
|
+
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
91
|
+
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: NAV_ITEMS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeItem?.label }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut })] }), _jsx("div", { className: "flex-1 pb-14 md:pb-0", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
|
|
92
|
+
}
|
|
93
|
+
return (_jsxs(_Fragment, { children: [_jsx(BrandingHeader, { branding: branding }), content, _jsx(BrandingFooter, { branding: branding })] }));
|
|
94
|
+
}
|