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