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