web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.
lib/admin/ adminAccess, adminDashboard, adminDirectory, adminDomains,
adminGroups, adminLists, adminRoles, adminTenants
lib/calendar/ appointment, availabilityWindow, eventDrag, ics, recurrence
Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.
recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.
docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.
No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
215 lines
8.7 KiB
TypeScript
215 lines
8.7 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useLocation } from "wouter";
|
||
import { ChevronLeft, ChevronRight, Plus, Search, UsersRound } from "lucide-react";
|
||
import { useSession } from "@/store/session";
|
||
import { STALWART_CAP } from "@/jmap/client";
|
||
import { can, type RoleDef } from "@/lib/admin/adminAccess";
|
||
import { describeDirectoryError, listDomains, listRoles, type DirectoryDomain } from "@/lib/admin/adminDirectory";
|
||
import { countMembers, getGroups, queryGroups, type DirectoryGroup } from "@/lib/admin/adminGroups";
|
||
import { plural, t } from "@/lib/i18n";
|
||
import { Avatar, Empty, Spinner } from "@/ui/misc";
|
||
import { usePermissions } from "./usePermissions";
|
||
import type { DirectoryContext } from "./directoryContext";
|
||
import { GroupSheet } from "./GroupSheet";
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
/**
|
||
* Groups: accounts that hold shared mail and the people who share it.
|
||
*
|
||
* Laid out as Accounts is -- search, a page of fifty, a panel beside the list
|
||
* -- because a group is an account to the server, with a member count where a
|
||
* person has a role.
|
||
*/
|
||
export function GroupsAdmin({ 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<{ groups: DirectoryGroup[]; total: number } | null>(null);
|
||
const [counts, setCounts] = useState<Map<string, number>>(new Map());
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [reload, setReload] = useState(0);
|
||
const [serverDomains, setServerDomains] = useState<DirectoryDomain[] | null>(null);
|
||
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
|
||
const [loose, setLoose] = useState<DirectoryGroup | null>(null);
|
||
|
||
useEffect(() => {
|
||
const id = window.setTimeout(() => {
|
||
setQuery(text);
|
||
setPosition(0);
|
||
}, 250);
|
||
return () => window.clearTimeout(id);
|
||
}, [text]);
|
||
|
||
useEffect(() => {
|
||
let canceled = false;
|
||
setError(null);
|
||
void (async () => {
|
||
try {
|
||
const q = await queryGroups({ text: query, position, limit: PAGE_SIZE });
|
||
const groups = await getGroups(q.ids);
|
||
if (canceled) return;
|
||
setPage({ groups, total: q.total });
|
||
void countMembers(groups.map((g) => g.id)).then((c) => !canceled && setCounts(c));
|
||
} catch (err) {
|
||
if (!canceled) {
|
||
setPage({ groups: [], total: 0 });
|
||
setError(describeDirectoryError(err, "group"));
|
||
}
|
||
}
|
||
})();
|
||
return () => {
|
||
canceled = true;
|
||
};
|
||
}, [query, position, reload]);
|
||
|
||
useEffect(() => {
|
||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
|
||
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
|
||
}, [perms, reload]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedId || selectedId === "new" || page?.groups.some((g) => g.id === selectedId)) {
|
||
setLoose(null);
|
||
return;
|
||
}
|
||
let canceled = false;
|
||
void getGroups([selectedId]).then(
|
||
([g]) => { if (!canceled) setLoose(g ?? null); },
|
||
() => { if (!canceled) setLoose(null); },
|
||
);
|
||
return () => {
|
||
canceled = true;
|
||
};
|
||
}, [selectedId, page]);
|
||
|
||
const ctx: DirectoryContext = useMemo(() => {
|
||
const seen = new Map<string, DirectoryDomain>();
|
||
for (const g of page?.groups ?? []) {
|
||
const domain = g.emailAddress?.split("@")[1];
|
||
if (domain && !seen.has(g.domainId)) seen.set(g.domainId, { id: g.domainId, name: domain });
|
||
}
|
||
const ownId = session?.primaryAccounts?.[STALWART_CAP];
|
||
return {
|
||
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
|
||
roles,
|
||
groups: new Map(),
|
||
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
|
||
};
|
||
}, [page, serverDomains, roles, session]);
|
||
|
||
const selected = selectedId && selectedId !== "new" ? (page?.groups.find((g) => g.id === selectedId) ?? loose) : null;
|
||
const close = () => navigate("/admin/groups");
|
||
const changed = () => setReload((n) => n + 1);
|
||
|
||
return (
|
||
<div>
|
||
<div className="admin-head">
|
||
<div className="grow">
|
||
<h1>{t("Groups")}</h1>
|
||
<p className="lead">{t("Shared addresses and mailboxes, and the people who share them.")}</p>
|
||
</div>
|
||
{can(perms, "Account", "Create") && (
|
||
<button className="btn btn-primary" onClick={() => navigate("/admin/groups/new")}>
|
||
<Plus size={16} /> {t("New group")}
|
||
</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 groups")} />
|
||
</label>
|
||
</div>
|
||
|
||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||
|
||
{page === null ? (
|
||
<Spinner />
|
||
) : page.groups.length === 0 ? (
|
||
!error && (
|
||
<Empty icon={<UsersRound size={32} />} title={query ? t("No groups match") : t("No groups 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("Group")}</th>
|
||
<th>{t("Members")}</th>
|
||
<th className="hide-mobile">{t("Other addresses")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{page.groups.map((g) => (
|
||
<tr
|
||
key={g.id}
|
||
className={g.id === selectedId ? "selected" : ""}
|
||
tabIndex={0}
|
||
onClick={() => navigate(`/admin/groups/${g.id}`)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
navigate(`/admin/groups/${g.id}`);
|
||
}
|
||
}}
|
||
aria-label={t("Open {address}", { address: g.emailAddress ?? g.name })}
|
||
>
|
||
<td>
|
||
<div className="admin-who">
|
||
<Avatar who={{ name: g.description || g.name, email: g.emailAddress }} size="sm" />
|
||
<div className="grow">
|
||
<div className="admin-who-name truncate">{g.description || g.name}</div>
|
||
<div className="hint truncate notranslate" translate="no">{g.emailAddress}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="muted" style={{ fontVariantNumeric: "tabular-nums" }}>{counts.has(g.id) ? counts.get(g.id) : "—"}</td>
|
||
<td className="hide-mobile muted">
|
||
<span className="truncate admin-groups notranslate" translate="no">{Object.values(g.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} group", other: "{n} groups" })}</p>
|
||
) : (
|
||
<div className="admin-pager">
|
||
<span className="hint">{t("{from}–{to} of {total}", { from: position + 1, to: position + page.groups.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.groups.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{(selectedId === "new" || selected) && (
|
||
<GroupSheet
|
||
key={selectedId}
|
||
group={selectedId === "new" ? null : selected}
|
||
ctx={ctx}
|
||
onClose={close}
|
||
onChanged={changed}
|
||
onCreated={(id) => {
|
||
changed();
|
||
navigate(`/admin/groups/${id}`);
|
||
}}
|
||
onDeleted={() => {
|
||
changed();
|
||
close();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|