Move Administration's section list into the folder pane

Administration's pages are tables, and the Settings-style second column
took width they need. The list of sections -- Directory > Accounts,
Mail > Domains -- now sits in the folder pane where Mail keeps its folders,
and the page is the open section alone, up to 1120px wide.

On a phone the list is in the drawer like every other section's, so a bare
/admin opens the first section rather than a page that is only a list. The
back link it needed is gone.
This commit is contained in:
2026-09-13 16:27:30 -07:00
parent 93c9660421
commit 7c0e278ee8
5 changed files with 123 additions and 41 deletions
+5 -2
View File
@@ -1679,8 +1679,11 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
one that is open. The panel is positioned against the layout rather than the one that is open. The panel is positioned against the layout rather than the
scrolling content, so it stays put while the list scrolls under it. scrolling content, so it stays put while the list scrolls under it.
========================================================================== */ ========================================================================== */
.admin-layout { position: relative; } /* One column: the section list is in the folder pane (AdminNav), so the
.admin-content { max-width: 960px; } table gets the width a second column would take. The height and scrolling
are what .settings-layout gave it. */
.admin-layout { position: relative; height: 100%; min-height: 0; flex: 1; display: flex; flex-direction: column; }
.admin-content { max-width: 1120px; flex: 1; min-height: 0; }
.admin-head { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; } .admin-head { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.admin-head .grow { min-width: 220px; } .admin-head .grow { min-width: 220px; }
.admin-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } .admin-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
+2 -1
View File
@@ -23,6 +23,7 @@ import { TranslateBoundary } from "@/ui/TranslateBoundary";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { hasAdministration } from "@/lib/adminAccess"; import { hasAdministration } from "@/lib/adminAccess";
import { usePermissions } from "./admin/usePermissions"; import { usePermissions } from "./admin/usePermissions";
import { AdminNav } from "./admin/AdminNav";
const PUSH_LABEL = { const PUSH_LABEL = {
connected: "Live updates connected", connected: "Live updates connected",
@@ -213,7 +214,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{section === "contacts" && <ContactsSidebar />} {section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />} {section === "files" && <FilesTree />}
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>} {section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
{section === "admin" && <div className="nav-section"><span>{t("Administration")}</span></div>} {section === "admin" && <AdminNav />}
</div> </div>
{(section === "mail" || section === "search") && <QuotaBar />} {(section === "mail" || section === "search") && <QuotaBar />}
<nav className="module-bar" aria-label={t("Go to")}> <nav className="module-bar" aria-label={t("Go to")}>
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Globe, User } from "lucide-react";
import { adminSections, type AdminSection } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { usePermissions } from "./usePermissions";
export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode }> = {
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
};
/** The section the address names, or the first the role can open. */
export function currentAdminSection(allowed: AdminSection[], requested: string | undefined): AdminSection | undefined {
return allowed.find((s) => s === requested) ?? allowed[0];
}
/**
* Administration's sections, in the folder pane.
*
* Settings keeps its list inside the page; Administration's pages are tables
* that want the width, so the list lives where Mail keeps its folders. On a
* phone that puts it in the drawer, which is where every other section's list
* already is. Only sections the role can read are listed.
*/
export function AdminNav() {
const [location] = useLocation();
const allowed = adminSections(usePermissions());
const current = currentAdminSection(allowed, location.split("/")[2]);
const groups = [...new Set(allowed.map((s) => ADMIN_SECTIONS[s].group))];
return (
<nav aria-label={t("Administration")}>
{groups.map((group) => (
<div key={group}>
<div className="nav-section"><span>{t(group)}</span></div>
{allowed
.filter((s) => ADMIN_SECTIONS[s].group === group)
.map((s) => (
<Link key={s} href={`/admin/${s}`} className={`nav-item ${current === s ? "active" : ""}`} title={t(ADMIN_SECTIONS[s].label)} aria-current={current === s ? "page" : undefined}>
{ADMIN_SECTIONS[s].icon}
<span className="nav-label">{t(ADMIN_SECTIONS[s].label)}</span>
</Link>
))}
</div>
))}
</nav>
);
}
+14 -38
View File
@@ -1,57 +1,33 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link, Redirect, useLocation } from "wouter"; import { Redirect } from "wouter";
import { ArrowLeft, Globe, User } from "lucide-react";
import { adminSections, type AdminSection } from "@/lib/adminAccess"; import { adminSections, type AdminSection } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { AccountsAdmin } from "./AccountsAdmin"; import { AccountsAdmin } from "./AccountsAdmin";
import { DomainsAdmin } from "./DomainsAdmin"; import { DomainsAdmin } from "./DomainsAdmin";
import { currentAdminSection } from "./AdminNav";
import { usePermissions } from "./usePermissions"; import { usePermissions } from "./usePermissions";
const SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode; render: (id?: string) => ReactNode }> = { const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
accounts: { group: "Directory", label: "Accounts", icon: <User size={18} />, render: (id) => <AccountsAdmin selectedId={id} /> }, accounts: (id) => <AccountsAdmin selectedId={id} />,
domains: { group: "Mail", label: "Domains", icon: <Globe size={18} />, render: (id) => <DomainsAdmin selectedId={id} /> }, domains: (id) => <DomainsAdmin selectedId={id} />,
}; };
/** /**
* Administration: what the signed-in account's Stalwart role lets it manage. * Administration: what the signed-in account's Stalwart role lets it manage.
* *
* Laid out like Settings, because it is the same kind of place -- a list of * The page is only the open section. Its list of sections is in the folder
* sections and the one that is open -- and on a phone it behaves the same way, * pane (see AdminNav), so the tables here get the width Settings spends on a
* the list first and a section on its own. Only the sections the role can read * second column. A section the role cannot read -- typed into the address bar,
* are listed; a section typed into the address bar that it cannot read opens * say -- opens the first one it can.
* the first one it can.
*/ */
export function AdminView({ section, id }: { section?: string; id?: string }) { export function AdminView({ section, id }: { section?: string; id?: string }) {
const [, navigate] = useLocation();
const allowed = adminSections(usePermissions()); const allowed = adminSections(usePermissions());
// Typed in by hand, or a role taken away since the menu was drawn. Stalwart // A role taken away since the menu was drawn. Stalwart would refuse every
// would refuse every call anyway; this spares the page of refusals. // call anyway; this spares the page of refusals.
if (!allowed.length) return <Redirect to="/mail" />; if (!allowed.length) return <Redirect to="/mail" />;
const current = allowed.find((s) => s === section) ?? allowed[0]!; const current = currentAdminSection(allowed, section)!;
const groups = [...new Set(allowed.map((s) => SECTIONS[s].group))];
return ( return (
<div className={`settings-layout admin-layout ${section ? "section" : "root"}`}> <div className="admin-layout">
<nav className="settings-nav" aria-label={t("Administration")}> <div className="settings-content admin-content">{RENDER[current](section === current ? id : undefined)}</div>
{groups.map((group) => (
<div key={group}>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t(group)}</span></div>
{allowed.filter((s) => SECTIONS[s].group === group).map((s) => (
<Link key={s} href={`/admin/${s}`} className={`nav-item ${current === s ? "active" : ""}`}>
{SECTIONS[s].icon}
<span className="nav-label">{t(SECTIONS[s].label)}</span>
</Link>
))}
</div>
))}
</nav>
<div className="settings-content admin-content">
{section && (
<button className="btn btn-ghost btn-sm admin-back" style={{ marginBottom: 8, marginLeft: -8 }} onClick={() => navigate("/admin")}>
<ArrowLeft size={16} /> {t("Administration")}
</button>
)}
{SECTIONS[current].render(section === current ? id : undefined)}
</div>
</div> </div>
); );
} }
@@ -0,0 +1,54 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import { AdminNav } from "../AdminNav";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const signIn = (permissions: string[]) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
/** The folder pane's list of Administration sections: only what the role can read. */
describe("the Administration list in the folder pane", () => {
let host: HTMLDivElement;
let root: Root;
const render = async (path: string) => {
const { hook } = memoryLocation({ path });
await act(async () => {
root.render(<Router hook={hook}><AdminNav /></Router>);
});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("lists each readable section under its group and marks the open one", async () => {
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
await render("/admin/domains/d1");
expect([...host.querySelectorAll(".nav-section")].map((e) => e.textContent)).toEqual(["Directory", "Mail"]);
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
});
it("treats a bare /admin as the first section, which is what the page opens", async () => {
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
await render("/admin");
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Accounts");
});
it("leaves out what the role cannot read", async () => {
signIn(["sysDomainQuery", "sysDomainGet"]);
await render("/admin");
expect(host.textContent).not.toContain("Accounts");
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
});
});