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:
@@ -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; }
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user