Files
inbuxa-webmail/web/src/views/admin/GroupSheet.tsx
T
jcoffey-dev f7712b1c1e Group the admin and calendar modules, and stop calling screenshots docs
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.
2026-09-15 22:44:53 -07:00

470 lines
19 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Search, Trash2, UserMinus, UserPlus, X } from "lucide-react";
import { can, canGrantRole } from "@/lib/admin/adminAccess";
import { aliasList, describeDirectoryError, quotasWithDisk, updateAccount, DISK_QUOTA, type EmailAlias } from "@/lib/admin/adminDirectory";
import {
createGroup,
destroyGroup,
groupRoleKey,
groupRolesFromKey,
listMembers,
searchUsers,
setMembership,
type DirectoryGroup,
type GroupMember,
} from "@/lib/admin/adminGroups";
import { formatSize } from "@/lib/format";
import { plural, t } from "@/lib/i18n";
import { Avatar, Spinner } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { Aliases } from "./AccountSheet";
import { isSelf, type DirectoryContext } from "./directoryContext";
import { usePermissions } from "./usePermissions";
const GIB = 1024 ** 3;
const gibOf = (bytes: number | undefined) => (bytes ? String(Math.round((bytes / GIB) * 10) / 10) : "");
const bytesOf = (gib: string) => {
const n = Number(gib.replace(",", "."));
return Number.isFinite(n) && n > 0 ? Math.round(n * GIB) : null;
};
interface Props {
/** Null to create one. */
group: DirectoryGroup | null;
ctx: DirectoryContext;
onClose: () => void;
onChanged: () => void;
onCreated: (id: string) => void;
onDeleted: () => void;
}
/**
* One group, opened beside the list.
*
* Its own fields save together, as an account's do. Members are not part of
* that save: each is a change to the *member's* account, made when it is
* asked for, because that is where Stalwart keeps it and a half-saved list of
* people is worse than a list that is always what the server has.
*/
export function GroupSheet({ group, ctx, onClose, onChanged, onCreated, onDeleted }: Props) {
const perms = usePermissions();
const creating = group === null;
const editable = creating ? can(perms, "Account", "Create") : can(perms, "Account", "Update");
const [description, setDescription] = useState(group?.description ?? "");
const [name, setName] = useState("");
const [domainId, setDomainId] = useState(ctx.domains[0]?.id ?? "");
const [role, setRole] = useState(groupRoleKey(group?.roles));
const [quota, setQuota] = useState(gibOf(group?.quotas?.[DISK_QUOTA]));
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(group?.aliases ?? {}));
const [members, setMembers] = useState<{ members: GroupMember[]; total: number } | null>(null);
const [membersError, setMembersError] = useState<string | null>(null);
const [membersRevision, setMembersRevision] = useState(0);
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]);
useEffect(() => {
if (!group) return;
let canceled = false;
setMembersError(null);
listMembers(group.id).then(
(m) => !canceled && setMembers(m),
(err) => {
if (canceled) return;
setMembers({ members: [], total: 0 });
setMembersError(describeDirectoryError(err, "group"));
},
);
return () => {
canceled = true;
};
}, [group, membersRevision]);
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
const address = group?.emailAddress ?? `${name}@${domainName(domainId)}`;
const roleOptions = useMemo(() => {
const options: { value: string; label: string }[] = [{ value: "Default", label: t("Default group role") }];
for (const r of ctx.roles?.values() ?? []) {
if (canGrantRole(perms, r.id, ctx.roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id });
}
if (!options.some((o) => o.value === role)) {
const ids = role.startsWith("custom:") ? role.slice(7).split(",") : [];
const label = ids.map((id) => ctx.roles?.get(id)?.description).filter(Boolean).join(", ") || t("Custom role");
options.push({ value: role, label });
}
return options;
}, [perms, ctx.roles, role]);
const run = async (work: () => Promise<void>) => {
setBusy(true);
setError(null);
try {
await work();
} catch (err) {
setError(describeDirectoryError(err, "group"));
} finally {
setBusy(false);
}
};
const save = () =>
run(async () => {
if (!group) {
if (!name.trim() || !domainId) {
setError(t("A group needs an address."));
return;
}
const id = await createGroup({ name, domainId, description, roles: groupRolesFromKey(role), diskQuotaBytes: bytesOf(quota) });
toast.success(t("Created {address}", { address }));
onCreated(id);
return;
}
const patch: Record<string, unknown> = {};
if ((group.description ?? "") !== description) patch.description = description.trim() || null;
if (groupRoleKey(group.roles) !== role) patch.roles = groupRolesFromKey(role);
if ((group.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(group.quotas, bytesOf(quota));
if (JSON.stringify(aliasList(Object.values(group.aliases ?? {}))) !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
if (!Object.keys(patch).length) {
onClose();
return;
}
await updateAccount(group.id, patch);
toast.success(t("Saved {address}", { address }));
onChanged();
});
const membersChanged = () => {
setMembersRevision((n) => n + 1);
onChanged();
};
const used = group?.usedDiskQuota ?? 0;
const limit = group?.quotas?.[DISK_QUOTA];
return (
<aside className="admin-sheet" aria-label={creating ? t("New group") : address}>
<div className="admin-sheet-head">
{group && <Avatar who={{ name: group.description || group.name, email: group.emailAddress }} />}
<div className="grow">
<h2 className="truncate">{creating ? t("New group") : group.description || group.name}</h2>
{group && <div className="hint truncate notranslate" translate="no">{group.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 groups but not change them.")}</p>}
<h3>{t("Profile")}</h3>
<div className="field">
<label htmlFor="admin-group-description">{t("Display name")}</label>
<input id="admin-group-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
</div>
{creating && (
<div className="field">
<label htmlFor="admin-group-name">{t("Address")}</label>
<div className="row admin-address">
<input id="admin-group-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 group on.")}</span>}
</div>
)}
{!creating && (
<>
<h3>{t("Members")}</h3>
<Members
group={group}
ctx={ctx}
members={members}
error={membersError}
editable={editable}
onChanged={membersChanged}
/>
<h3>{t("Other addresses")}</h3>
<Aliases
aliases={aliases}
setAliases={setAliases}
editable={editable}
domains={ctx.domains}
defaultDomain={group.domainId}
domainName={domainName}
hint={t("Mail to these addresses is delivered to this group. Changes apply when you save.")}
/>
</>
)}
<h3>{t("Role")}</h3>
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
{roleOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p className="hint">{t("What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.")}</p>
<h3>{t("Storage")}</h3>
{!creating && (
<p className="hint" style={{ marginTop: 0 }}>
{limit ? t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) }) : t("{used} · no limit", { used: formatSize(used) })}
</p>
)}
<div className="field">
<label htmlFor="admin-group-quota">{t("Limit in GB")}</label>
<input id="admin-group-quota" className="input admin-narrow" inputMode="decimal" value={quota} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuota(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "Account", "Destroy") && (
<DeleteGroup
group={group}
memberIds={members?.members.map((m) => m.id) ?? null}
total={members?.total ?? 0}
blocked={
members === null
? t("Loading the group's members…")
: members.total > members.members.length
? t("This group has more members than can be taken out at once.")
: members.total > 0 && !can(perms, "Account", "Update")
? t("Deleting a group takes its members out of it first, and your role can't change their accounts.")
: null
}
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 group") : t("Save changes")}
</button>
</div>
)}
</aside>
);
}
function Members({ group, ctx, members, error, editable, onChanged }: {
group: DirectoryGroup;
ctx: DirectoryContext;
members: { members: GroupMember[]; total: number } | null;
error: string | null;
editable: boolean;
onChanged: () => void;
}) {
const [busyId, setBusyId] = useState<string | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const change = async (member: GroupMember, join: boolean) => {
setBusyId(member.id);
setFailure(null);
try {
await setMembership([member.id], group.id, join);
const who = member.emailAddress ?? member.name;
toast.success(join ? t("Added {address} to the group", { address: who }) : t("Removed {address} from the group", { address: who }));
onChanged();
} catch (err) {
setFailure(describeDirectoryError(err, "group"));
} finally {
setBusyId(null);
}
};
if (members === null) return <Spinner />;
const present = new Set(members.members.map((m) => m.id));
return (
<div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{members.members.length ? (
<ul className="admin-members">
{members.members.map((m) => {
const self = isSelf(m, ctx);
return (
<li key={m.id}>
<Avatar who={{ name: m.description || m.name, email: m.emailAddress }} size="sm" />
<div className="grow" style={{ minWidth: 0 }}>
<div className="truncate">
{m.description || m.name}
{self && <span className="badge muted">{t("You")}</span>}
</div>
<div className="hint truncate notranslate" translate="no">{m.emailAddress}</div>
</div>
{editable && (
<button
className="icon-btn sm"
aria-label={t("Remove {address} from the group", { address: m.emailAddress ?? m.name })}
title={self ? t("You can't change your own group memberships.") : t("Remove from group")}
disabled={self || busyId !== null}
onClick={() => void change(m, false)}
>
<UserMinus size={16} />
</button>
)}
</li>
);
})}
</ul>
) : (
!error && <p className="hint" style={{ marginTop: 0 }}>{t("No members yet")}</p>
)}
{members.total > members.members.length && (
<p className="hint">{t("Showing {shown} of {total} members.", { shown: members.members.length, total: members.total })}</p>
)}
{failure && <p className="admin-notice error" role="alert">{failure}</p>}
{editable && <AddMember ctx={ctx} exclude={present} busy={busyId !== null} onAdd={(m) => void change(m, true)} />}
<p className="hint">{t("Members get what is shared with the group, such as its mailbox. Changes apply straight away.")}</p>
</div>
);
}
function AddMember({ ctx, exclude, busy, onAdd }: { ctx: DirectoryContext; exclude: Set<string>; busy: boolean; onAdd: (m: GroupMember) => void }) {
const [text, setText] = useState("");
const [found, setFound] = useState<GroupMember[] | null>(null);
useEffect(() => {
const needle = text.trim();
if (!needle) {
setFound(null);
return;
}
let canceled = false;
const id = window.setTimeout(() => {
searchUsers(needle).then(
(list) => !canceled && setFound(list),
() => !canceled && setFound([]),
);
}, 250);
return () => {
canceled = true;
window.clearTimeout(id);
};
}, [text]);
const offered = (found ?? []).filter((m) => !exclude.has(m.id));
return (
<div className="admin-add-member">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input className="input" type="search" value={text} placeholder={t("Add a member by name or address")} aria-label={t("Add a member")} onChange={(e) => setText(e.target.value)} />
</label>
{found !== null && (
<ul className="admin-members admin-suggestions">
{offered.length ? (
offered.map((m) => {
const self = isSelf(m, ctx);
return (
<li key={m.id}>
<Avatar who={{ name: m.description || m.name, email: m.emailAddress }} size="sm" />
<div className="grow" style={{ minWidth: 0 }}>
<div className="truncate">{m.description || m.name}</div>
<div className="hint truncate notranslate" translate="no">{m.emailAddress}</div>
</div>
<button
className="btn btn-sm"
disabled={busy || self}
title={self ? t("You can't change your own group memberships.") : undefined}
onClick={() => {
onAdd(m);
setText("");
}}
>
<UserPlus size={14} /> {t("Add")}
</button>
</li>
);
})
) : (
<li className="hint">{t("No one else matches")}</li>
)}
</ul>
)}
</div>
);
}
function DeleteGroup({ group, memberIds, total, blocked, onDeleted }: { group: DirectoryGroup; memberIds: string[] | null; total: number; blocked: string | null; 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 = group.emailAddress ?? group.name;
return (
<>
<h3>{t("Delete")}</h3>
<div className="admin-danger">
<p>{blocked ?? t("Deletes the group and its mailbox. Its members' own accounts stay.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Delete group…")}
</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 destroyGroup(group.id, memberIds ?? []);
toast.success(t("Deleted {address}", { address }));
setOpen(false);
onDeleted();
} catch (err) {
setError(describeDirectoryError(err, "group"));
} finally {
setBusy(false);
}
}}
>
{t("Delete group")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>
{total > 0
? plural(total, {
one: "Its {n} member is taken out of the group first, and loses what was shared with it. The group's own mail is removed in the background, and it can't be undone.",
other: "Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.",
})
: t("The group's own mail is removed in the background, and it can't be undone.")}
</p>
<div className="field">
<label htmlFor="admin-group-delete-confirm">{t("Type {address} to confirm", { address })}</label>
<input id="admin-group-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>
</>
);
}