Add Mailing lists to Administration
A mailing list is an address that passes mail on to everyone on it. To Stalwart it is its own object, x:MailingList, behind sysMailingList*, so it gets its own section under Directory after Groups: search, fifty to a page with each list's recipient count, and a panel to create, edit and delete one. Recipients are a property of the list, so unlike a group's members they save with the rest of the panel. What Save sends for them is only what was added and removed, one recipients/<address> pointer each -- the patch the live server accepted -- so a recipient added elsewhere while the panel was open is not taken out. They can be pasted several at a time, from a spreadsheet column, a comma-separated line or Name <address>; anything with an @ that is not an address stays in the box with a note. Past a dozen, a filter narrows them. That is all a list is in Stalwart -- no owners, moderation or posting rules -- so that is all the panel offers. The mock answers x:MailingList with two lists, the recipient set's live shape, and the refusals a wrong address, a clash with an account and a missing permission get. Twenty-five new strings and one plural, in all nine catalogues.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Globe, LayoutDashboard, User, UsersRound } from "lucide-react";
|
||||
import { Globe, LayoutDashboard, List, User, UsersRound } from "lucide-react";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
@@ -9,6 +9,7 @@ export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string
|
||||
dashboard: { group: "Overview", label: "Dashboard", icon: <LayoutDashboard size={20} /> },
|
||||
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
|
||||
groups: { group: "Directory", label: "Groups", icon: <UsersRound size={20} /> },
|
||||
lists: { group: "Directory", label: "Mailing lists", icon: <List size={20} /> },
|
||||
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AccountsAdmin } from "./AccountsAdmin";
|
||||
import { AdminDashboard } from "./AdminDashboard";
|
||||
import { DomainsAdmin } from "./DomainsAdmin";
|
||||
import { GroupsAdmin } from "./GroupsAdmin";
|
||||
import { ListsAdmin } from "./ListsAdmin";
|
||||
import { currentAdminSection } from "./AdminNav";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
@@ -12,6 +13,7 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
||||
dashboard: () => <AdminDashboard />,
|
||||
accounts: (id) => <AccountsAdmin selectedId={id} />,
|
||||
groups: (id) => <GroupsAdmin selectedId={id} />,
|
||||
lists: (id) => <ListsAdmin selectedId={id} />,
|
||||
domains: (id) => <DomainsAdmin selectedId={id} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Search, Trash2, X } from "lucide-react";
|
||||
import { can } from "@/lib/adminAccess";
|
||||
import { aliasList, describeDirectoryError, type EmailAlias } from "@/lib/adminDirectory";
|
||||
import { createList, destroyList, parseAddresses, recipientsPatch, updateList, type DirectoryList } from "@/lib/adminLists";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { Aliases } from "./AccountSheet";
|
||||
import type { DirectoryContext } from "./directoryContext";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
interface Props {
|
||||
/** Null to create one. */
|
||||
list: DirectoryList | null;
|
||||
ctx: DirectoryContext;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onCreated: (id: string) => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
/** Past this many, the recipients get a filter of their own. */
|
||||
const FILTER_FROM = 12;
|
||||
|
||||
/**
|
||||
* One mailing list, opened beside the table.
|
||||
*
|
||||
* Everything on it saves together, recipients included: they are a property of
|
||||
* the list itself, unlike a group's members. What Save sends for them is only
|
||||
* the addresses added and removed, one pointer each, so a recipient added
|
||||
* elsewhere while this was open is not lost by saving it.
|
||||
*/
|
||||
export function ListSheet({ list, ctx, onClose, onChanged, onCreated, onDeleted }: Props) {
|
||||
const perms = usePermissions();
|
||||
const creating = list === null;
|
||||
const editable = creating ? can(perms, "MailingList", "Create") : can(perms, "MailingList", "Update");
|
||||
const original = useMemo(() => Object.keys(list?.recipients ?? {}), [list]);
|
||||
|
||||
const [description, setDescription] = useState(list?.description ?? "");
|
||||
const [name, setName] = useState("");
|
||||
const [domainId, setDomainId] = useState(ctx.domains[0]?.id ?? "");
|
||||
const [recipients, setRecipients] = useState<string[]>(original);
|
||||
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(list?.aliases ?? {}));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
|
||||
}, [ctx.domains, domainId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
|
||||
const address = list?.emailAddress ?? `${name}@${domainName(domainId)}`;
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!list) {
|
||||
if (!name.trim() || !domainId) {
|
||||
setError(t("A list needs an address."));
|
||||
return;
|
||||
}
|
||||
const id = await createList({ name, domainId, description, recipients });
|
||||
toast.success(t("Created {address}", { address }));
|
||||
onCreated(id);
|
||||
return;
|
||||
}
|
||||
const patch: Record<string, unknown> = { ...recipientsPatch(original, recipients) };
|
||||
if ((list.description ?? "") !== description) patch.description = description.trim() || null;
|
||||
if (JSON.stringify(aliasList(Object.values(list.aliases ?? {}))) !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
|
||||
if (!Object.keys(patch).length) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await updateList(list.id, patch);
|
||||
toast.success(t("Saved {address}", { address }));
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err, "list"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="admin-sheet" aria-label={creating ? t("New mailing list") : address}>
|
||||
<div className="admin-sheet-head">
|
||||
<div className="grow">
|
||||
<h2 className="truncate">{creating ? t("New mailing list") : list.description || list.name}</h2>
|
||||
{list && <div className="hint truncate notranslate" translate="no">{list.emailAddress}</div>}
|
||||
</div>
|
||||
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-sheet-body">
|
||||
{!creating && !editable && <p className="admin-notice">{t("Your role lets you view mailing lists but not change them.")}</p>}
|
||||
|
||||
<h3>{t("Profile")}</h3>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-list-description">{t("Display name")}</label>
|
||||
<input id="admin-list-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
{creating && (
|
||||
<div className="field">
|
||||
<label htmlFor="admin-list-name">{t("Address")}</label>
|
||||
<div className="row admin-address">
|
||||
<input id="admin-list-name" className="input" value={name} autoComplete="off" spellCheck={false} onChange={(e) => setName(e.target.value.trim().toLowerCase())} />
|
||||
<span className="muted">@</span>
|
||||
<select className="input" aria-label={t("Domain")} value={domainId} onChange={(e) => setDomainId(e.target.value)}>
|
||||
{ctx.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!ctx.domains.length && <span className="hint">{t("No domains are available to create a list on.")}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3>{t("Recipients")}</h3>
|
||||
<Recipients recipients={recipients} setRecipients={setRecipients} editable={editable} />
|
||||
|
||||
{!creating && (
|
||||
<>
|
||||
<h3>{t("Other addresses")}</h3>
|
||||
<Aliases
|
||||
aliases={aliases}
|
||||
setAliases={setAliases}
|
||||
editable={editable}
|
||||
domains={ctx.domains}
|
||||
defaultDomain={list.domainId}
|
||||
domainName={domainName}
|
||||
hint={t("Mail to these addresses goes to the list too. Changes apply when you save.")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{!creating && can(perms, "MailingList", "Destroy") && <DeleteList list={list} onDeleted={onDeleted} />}
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div className="admin-sheet-foot">
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={busy || (creating && (!name || !domainId))} onClick={() => void save()}>
|
||||
{creating ? t("Create list") : t("Save changes")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Recipients({ recipients, setRecipients, editable }: { recipients: string[]; setRecipients: (r: string[]) => void; editable: boolean }) {
|
||||
const [text, setText] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [rejected, setRejected] = useState<string[]>([]);
|
||||
|
||||
const add = () => {
|
||||
const { addresses, rejected: bad } = parseAddresses(text);
|
||||
const have = new Set(recipients.map((r) => r.toLowerCase()));
|
||||
const fresh = addresses.filter((a) => !have.has(a.toLowerCase()));
|
||||
if (fresh.length) setRecipients([...recipients, ...fresh]);
|
||||
setRejected(bad);
|
||||
// Keep what could not be read in the box, so it can be corrected.
|
||||
setText(bad.join(", "));
|
||||
};
|
||||
|
||||
const needle = filter.trim().toLowerCase();
|
||||
const shown = needle ? recipients.filter((r) => r.toLowerCase().includes(needle)) : recipients;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="hint" style={{ marginTop: 0 }}>{plural(recipients.length, { one: "{n} recipient", other: "{n} recipients" })}</p>
|
||||
{recipients.length > FILTER_FROM && (
|
||||
<label className="admin-search admin-recipient-filter">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input className="input" type="search" value={filter} placeholder={t("Filter recipients")} aria-label={t("Filter recipients")} onChange={(e) => setFilter(e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
{recipients.length > 0 && (
|
||||
<div className="row wrap gap-4 admin-recipients">
|
||||
{shown.map((r) => (
|
||||
<span key={r.toLowerCase()} className="chip notranslate" translate="no">
|
||||
{r}
|
||||
{editable && (
|
||||
<button className="chip-x" aria-label={t("Remove {address}", { address: r })} onClick={() => setRecipients(recipients.filter((x) => x !== r))}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!shown.length && <span className="hint">{t("No recipients match")}</span>}
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<div className="row mt-8">
|
||||
<input
|
||||
className="input grow"
|
||||
aria-label={t("Add recipients")}
|
||||
placeholder={t("Addresses, separated by commas")}
|
||||
value={text}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-sm" onClick={add} disabled={!text.trim()}>
|
||||
<Plus size={14} /> {t("Add")}
|
||||
</button>
|
||||
</div>
|
||||
{rejected.length > 0 && (
|
||||
<p className="admin-notice warn" role="alert">{t("Not added, as they aren't addresses: {items}", { items: rejected.join(", ") })}</p>
|
||||
)}
|
||||
<p className="hint">{t("Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.")}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteList({ list, onDeleted }: { list: DirectoryList; onDeleted: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const address = list.emailAddress ?? list.name;
|
||||
return (
|
||||
<>
|
||||
<h3>{t("Delete")}</h3>
|
||||
<div className="admin-danger">
|
||||
<p>{t("Mail to this address stops being passed on. The recipients' own mail is untouched.")}</p>
|
||||
<button className="btn btn-sm admin-danger-btn" onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
|
||||
<Trash2 size={14} /> {t("Delete list…")}
|
||||
</button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t("Delete {address}?", { address })}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={busy || typed.trim().toLowerCase() !== address.toLowerCase()}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await destroyList(list.id);
|
||||
toast.success(t("Deleted {address}", { address }));
|
||||
setOpen(false);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err, "list"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Delete list")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ marginTop: 0 }}>{t("Mail to this address is no longer passed on to anyone. It can't be undone.")}</p>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-list-delete-confirm">{t("Type {address} to confirm", { address })}</label>
|
||||
<input id="admin-list-delete-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, List, Plus, Search } from "lucide-react";
|
||||
import { can } from "@/lib/adminAccess";
|
||||
import { describeDirectoryError, listDomains, type DirectoryDomain } from "@/lib/adminDirectory";
|
||||
import { getLists, queryLists, type DirectoryList } from "@/lib/adminLists";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { useSession } from "@/store/session";
|
||||
import { STALWART_CAP } from "@/jmap/client";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
import type { DirectoryContext } from "./directoryContext";
|
||||
import { ListSheet } from "./ListSheet";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Mailing lists: an address that passes mail on to others.
|
||||
*
|
||||
* The same shape as Accounts and Groups -- search, fifty to a page, a panel --
|
||||
* with the number of recipients where they have a role or members.
|
||||
*/
|
||||
export function ListsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const perms = usePermissions();
|
||||
const session = useSession((s) => s.session);
|
||||
const [text, setText] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [position, setPosition] = useState(0);
|
||||
const [page, setPage] = useState<{ lists: DirectoryList[]; total: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [serverDomains, setServerDomains] = useState<DirectoryDomain[] | null>(null);
|
||||
const [loose, setLoose] = useState<DirectoryList | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setQuery(text);
|
||||
setPosition(0);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const q = await queryLists({ text: query, position, limit: PAGE_SIZE });
|
||||
const lists = await getLists(q.ids);
|
||||
if (!cancelled) setPage({ lists, total: q.total });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setPage({ lists: [], total: 0 });
|
||||
setError(describeDirectoryError(err, "list"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, position, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
|
||||
}, [perms, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedId === "new" || page?.lists.some((l) => l.id === selectedId)) {
|
||||
setLoose(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getLists([selectedId]).then(
|
||||
([l]) => { if (!cancelled) setLoose(l ?? null); },
|
||||
() => { if (!cancelled) setLoose(null); },
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedId, page]);
|
||||
|
||||
const ctx: DirectoryContext = useMemo(() => {
|
||||
const seen = new Map<string, DirectoryDomain>();
|
||||
for (const l of page?.lists ?? []) {
|
||||
const domain = l.emailAddress?.split("@")[1];
|
||||
if (domain && !seen.has(l.domainId)) seen.set(l.domainId, { id: l.domainId, name: domain });
|
||||
}
|
||||
const ownId = session?.primaryAccounts?.[STALWART_CAP];
|
||||
return {
|
||||
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
|
||||
roles: null,
|
||||
groups: new Map(),
|
||||
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
|
||||
};
|
||||
}, [page, serverDomains, session]);
|
||||
|
||||
const selected = selectedId && selectedId !== "new" ? (page?.lists.find((l) => l.id === selectedId) ?? loose) : null;
|
||||
const close = () => navigate("/admin/lists");
|
||||
const changed = () => setReload((n) => n + 1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-head">
|
||||
<div className="grow">
|
||||
<h1>{t("Mailing lists")}</h1>
|
||||
<p className="lead">{t("Addresses that pass mail on to everyone on them.")}</p>
|
||||
</div>
|
||||
{can(perms, "MailingList", "Create") && (
|
||||
<button className="btn btn-primary" onClick={() => navigate("/admin/lists/new")}>
|
||||
<Plus size={16} /> {t("New mailing list")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<label className="admin-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input className="input" type="search" value={text} onChange={(e) => setText(e.target.value)} placeholder={t("Search by name or address")} aria-label={t("Search mailing lists")} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{page === null ? (
|
||||
<Spinner />
|
||||
) : page.lists.length === 0 ? (
|
||||
!error && (
|
||||
<Empty icon={<List size={32} />} title={query ? t("No mailing lists match") : t("No mailing lists yet")}>
|
||||
{query ? t("Nothing on your domains matches “{query}”.", { query }) : undefined}
|
||||
</Empty>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("Mailing list")}</th>
|
||||
<th>{t("Recipients")}</th>
|
||||
<th className="hide-mobile">{t("Other addresses")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{page.lists.map((l) => (
|
||||
<tr
|
||||
key={l.id}
|
||||
className={l.id === selectedId ? "selected" : ""}
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/admin/lists/${l.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/admin/lists/${l.id}`);
|
||||
}
|
||||
}}
|
||||
aria-label={t("Open {address}", { address: l.emailAddress ?? l.name })}
|
||||
>
|
||||
<td>
|
||||
<div className="admin-who-name truncate">{l.description || l.name}</div>
|
||||
<div className="hint truncate notranslate" translate="no">{l.emailAddress}</div>
|
||||
</td>
|
||||
<td className="muted" style={{ fontVariantNumeric: "tabular-nums" }}>{Object.keys(l.recipients ?? {}).length}</td>
|
||||
<td className="hide-mobile muted">
|
||||
<span className="truncate admin-groups notranslate" translate="no">{Object.values(l.aliases ?? {}).map((a) => a.name).join(", ") || "—"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{page.total <= PAGE_SIZE && position === 0 ? (
|
||||
<p className="hint admin-count">{plural(page.total, { one: "{n} mailing list", other: "{n} mailing lists" })}</p>
|
||||
) : (
|
||||
<div className="admin-pager">
|
||||
<span className="hint">{t("{from}–{to} of {total}", { from: position + 1, to: position + page.lists.length, total: page.total })}</span>
|
||||
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => setPosition(Math.max(0, position - PAGE_SIZE))}><ChevronLeft size={18} /></button>
|
||||
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + page.lists.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedId === "new" || selected) && (
|
||||
<ListSheet
|
||||
key={selectedId}
|
||||
list={selectedId === "new" ? null : selected}
|
||||
ctx={ctx}
|
||||
onClose={close}
|
||||
onChanged={changed}
|
||||
onCreated={(id) => {
|
||||
changed();
|
||||
navigate(`/admin/lists/${id}`);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
changed();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import type { DirectoryList } from "@/lib/adminLists";
|
||||
import type { DirectoryContext } from "../directoryContext";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const api = vi.hoisted(() => ({ updateList: vi.fn(async () => {}) }));
|
||||
vi.mock("@/lib/adminLists", async (original) => ({ ...(await original<typeof import("@/lib/adminLists")>()), updateList: api.updateList }));
|
||||
|
||||
const { ListSheet } = await import("../ListSheet");
|
||||
|
||||
const list: DirectoryList = { id: "l1", name: "announce", domainId: "d1", emailAddress: "[email protected]", description: "Announcements", recipients: { "[email protected]": true, "[email protected]": true }, aliases: {} };
|
||||
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: null, groups: new Map(), self: { ids: new Set(), address: "[email protected]" } };
|
||||
const signIn = (permissions: string[]) =>
|
||||
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
|
||||
const button = (host: HTMLElement, label: string) => [...host.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === label || b.textContent?.trim() === label);
|
||||
const type = async (input: HTMLInputElement, value: string) => {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
describe("the mailing list sheet", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(<ListSheet list={list} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
|
||||
});
|
||||
};
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
api.updateList.mockClear();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("saves only the recipients added and removed, and says what it could not read", async () => {
|
||||
signIn(["sysMailingListGet", "sysMailingListQuery", "sysMailingListUpdate"]);
|
||||
await render();
|
||||
await act(async () => button(host, "Remove [email protected]")!.click());
|
||||
await type(host.querySelector<HTMLInputElement>('input[aria-label="Add recipients"]')!, "Bob <[email protected]>, oops@");
|
||||
await act(async () => button(host, "Add")!.click());
|
||||
expect(host.querySelector(".admin-notice.warn")?.textContent).toContain("oops@");
|
||||
expect(host.querySelector<HTMLInputElement>('input[aria-label="Add recipients"]')!.value).toBe("oops@");
|
||||
await act(async () => button(host, "Save changes")!.click());
|
||||
expect(api.updateList).toHaveBeenCalledWith("l1", { "recipients/[email protected]": null, "recipients/[email protected]": true });
|
||||
});
|
||||
|
||||
it("offers nothing to change to a role that can only read, and no delete without the permission", async () => {
|
||||
signIn(["sysMailingListGet", "sysMailingListQuery"]);
|
||||
await render();
|
||||
expect(host.textContent).toContain("Your role lets you view mailing lists but not change them.");
|
||||
expect(button(host, "Remove [email protected]")).toBeUndefined();
|
||||
expect(host.querySelector('input[aria-label="Add recipients"]')).toBeNull();
|
||||
expect(button(host, "Delete list…")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user