Merge pull request #337 from Coffey-Labs/feat/admin-nav-in-sidebar

Move Administration's section list into the folder pane
This commit is contained in:
jcoffey
2026-09-13 16:36:22 -07:00
committed by GitHub
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
scrolling content, so it stays put while the list scrolls under it.
========================================================================== */
.admin-layout { position: relative; }
.admin-content { max-width: 960px; }
/* One column: the section list is in the folder pane (AdminNav), so the
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 .grow { min-width: 220px; }
.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 { hasAdministration } from "@/lib/adminAccess";
import { usePermissions } from "./admin/usePermissions";
import { AdminNav } from "./admin/AdminNav";
const PUSH_LABEL = {
connected: "Live updates connected",
@@ -213,7 +214,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />}
{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>
{(section === "mail" || section === "search") && <QuotaBar />}
<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 { Link, Redirect, useLocation } from "wouter";
import { ArrowLeft, Globe, User } from "lucide-react";
import { Redirect } from "wouter";
import { adminSections, type AdminSection } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { AccountsAdmin } from "./AccountsAdmin";
import { DomainsAdmin } from "./DomainsAdmin";
import { currentAdminSection } from "./AdminNav";
import { usePermissions } from "./usePermissions";
const SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode; render: (id?: string) => ReactNode }> = {
accounts: { group: "Directory", label: "Accounts", icon: <User size={18} />, render: (id) => <AccountsAdmin selectedId={id} /> },
domains: { group: "Mail", label: "Domains", icon: <Globe size={18} />, render: (id) => <DomainsAdmin selectedId={id} /> },
const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
accounts: (id) => <AccountsAdmin selectedId={id} />,
domains: (id) => <DomainsAdmin selectedId={id} />,
};
/**
* 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
* sections and the one that is open -- and on a phone it behaves the same way,
* the list first and a section on its own. Only the sections the role can read
* are listed; a section typed into the address bar that it cannot read opens
* the first one it can.
* The page is only the open section. Its list of sections is in the folder
* pane (see AdminNav), so the tables here get the width Settings spends on a
* second column. A section the role cannot read -- typed into the address bar,
* say -- opens the first one it can.
*/
export function AdminView({ section, id }: { section?: string; id?: string }) {
const [, navigate] = useLocation();
const allowed = adminSections(usePermissions());
// Typed in by hand, or a role taken away since the menu was drawn. Stalwart
// would refuse every call anyway; this spares the page of refusals.
// A role taken away since the menu was drawn. Stalwart would refuse every
// call anyway; this spares the page of refusals.
if (!allowed.length) return <Redirect to="/mail" />;
const current = allowed.find((s) => s === section) ?? allowed[0]!;
const groups = [...new Set(allowed.map((s) => SECTIONS[s].group))];
const current = currentAdminSection(allowed, section)!;
return (
<div className={`settings-layout admin-layout ${section ? "section" : "root"}`}>
<nav className="settings-nav" aria-label={t("Administration")}>
{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 className="admin-layout">
<div className="settings-content admin-content">{RENDER[current](section === current ? id : undefined)}</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");
});
});