feat(admin): configurable sidebar menu order and visibility
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
1127807aaa
commit
0224b34f15
11 files changed
+700
-5
No files matched your search
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
type AdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const schema = z.object({
|
||||
groupOrder: z.array(z.string()),
|
||||
hiddenGroups: z.array(z.string()),
|
||||
hiddenItems: z.array(z.string()),
|
||||
itemOrder: z.record(z.string(), z.array(z.string())),
|
||||
});
|
||||
|
||||
export const saveAdminNavConfig = adminAction(
|
||||
{
|
||||
permission: PERMS.SETTINGS_EDIT,
|
||||
schema,
|
||||
rateLimitKey: "admin-nav-config-save",
|
||||
rateLimitMax: 30,
|
||||
},
|
||||
async (ctx) => {
|
||||
const config: AdminNavConfig = {
|
||||
groupOrder: ctx.data.groupOrder,
|
||||
hiddenGroups: ctx.data.hiddenGroups,
|
||||
hiddenItems: ctx.data.hiddenItems,
|
||||
itemOrder: ctx.data.itemOrder,
|
||||
};
|
||||
await siteSettings.update(
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
serializeAdminNavConfig(config),
|
||||
);
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
return actionOk({ saved: true });
|
||||
},
|
||||
);
|
||||
@@ -11,6 +11,10 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
parseAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { setCsrfCookie } from "@/lib/foundation/security";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -86,6 +90,10 @@ async function Sidebar({
|
||||
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
||||
});
|
||||
|
||||
const navConfig = parseAdminNavConfig(
|
||||
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-admin
|
||||
@@ -115,6 +123,7 @@ async function Sidebar({
|
||||
<AdminSidebarNav
|
||||
allowedPermissions={allowedPermissions}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
navConfig={navConfig}
|
||||
/>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
import { ListOrdered } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
parseAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { AdminMenuEditor } from "./admin-menu-editor";
|
||||
|
||||
export const metadata = { title: "Admin menu" };
|
||||
|
||||
export default async function AdminMenuPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const canEdit = canAccess(
|
||||
permissions,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
const t = await getTranslations("pages.admin.menu");
|
||||
|
||||
const catalog = ADMIN_NAV_GROUPS.map((g) => ({
|
||||
labelKey: g.labelKey,
|
||||
items: g.items.map((i) => ({ href: i.href, labelKey: i.labelKey })),
|
||||
}));
|
||||
const initialConfig = parseAdminNavConfig(
|
||||
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={ListOrdered}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
>
|
||||
<AdminMenuEditor
|
||||
catalog={catalog}
|
||||
initialConfig={initialConfig}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
type AdminNavItem,
|
||||
navItemIsAllowed,
|
||||
} from "@/lib/admin-nav";
|
||||
import {
|
||||
type AdminNavConfig,
|
||||
applyAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STORAGE_KEY = "admin-nav-collapsed";
|
||||
@@ -59,10 +63,13 @@ function persistCollapsed(next: Record<string, boolean>) {
|
||||
export function AdminSidebarNav({
|
||||
allowedPermissions = [],
|
||||
isSuperAdmin = false,
|
||||
navConfig = {},
|
||||
}: {
|
||||
/** ACL slugs the staff member holds (ignored when isSuperAdmin). */
|
||||
allowedPermissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
/** Owner overlay: hide + reorder groups/items (from website_settings). */
|
||||
navConfig?: AdminNavConfig;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.nav");
|
||||
const pathname = usePathname() ?? "";
|
||||
@@ -79,11 +86,13 @@ export function AdminSidebarNav({
|
||||
isSuperAdmin,
|
||||
has: (slug: string) => allowed.has(slug),
|
||||
};
|
||||
return ADMIN_NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => navItemIsAllowed(item, access)),
|
||||
})).filter((group) => group.items.length > 0);
|
||||
}, [allowed, isSuperAdmin]);
|
||||
return applyAdminNavConfig(ADMIN_NAV_GROUPS, navConfig)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => navItemIsAllowed(item, access)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}, [allowed, isSuperAdmin, navConfig]);
|
||||
|
||||
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { LayoutDashboard, Newspaper, Settings } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdminNavGroup } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_PINNED_HREFS,
|
||||
applyAdminNavConfig,
|
||||
parseAdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
|
||||
const catalog: AdminNavGroup[] = [
|
||||
{
|
||||
labelKey: "overview",
|
||||
icon: LayoutDashboard,
|
||||
items: [
|
||||
{ href: "/admin", labelKey: "dashboard", icon: LayoutDashboard },
|
||||
{ href: "/admin/menu", labelKey: "menu", icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "content",
|
||||
icon: Newspaper,
|
||||
items: [
|
||||
{ href: "/admin/articles", labelKey: "articles", icon: Newspaper },
|
||||
{ href: "/admin/photos", labelKey: "photos", icon: Newspaper },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "system",
|
||||
icon: Settings,
|
||||
items: [{ href: "/admin/settings", labelKey: "settings", icon: Settings }],
|
||||
},
|
||||
];
|
||||
|
||||
describe("admin-nav-config", () => {
|
||||
it("parses empty / invalid as empty config", () => {
|
||||
expect(parseAdminNavConfig(null)).toEqual({});
|
||||
expect(parseAdminNavConfig("")).toEqual({});
|
||||
expect(parseAdminNavConfig("not-json")).toEqual({});
|
||||
expect(parseAdminNavConfig("[]")).toEqual({});
|
||||
});
|
||||
|
||||
it("reorders groups and items", () => {
|
||||
const applied = applyAdminNavConfig(catalog, {
|
||||
groupOrder: ["system", "content", "overview"],
|
||||
itemOrder: {
|
||||
content: ["/admin/photos", "/admin/articles"],
|
||||
},
|
||||
});
|
||||
expect(applied.map((g) => g.labelKey)).toEqual([
|
||||
"system",
|
||||
"content",
|
||||
"overview",
|
||||
]);
|
||||
expect(applied[1]?.items.map((i) => i.href)).toEqual([
|
||||
"/admin/photos",
|
||||
"/admin/articles",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides groups and items but keeps pinned hrefs", () => {
|
||||
const applied = applyAdminNavConfig(catalog, {
|
||||
hiddenGroups: ["system"],
|
||||
hiddenItems: ["/admin/photos", "/admin", "/admin/menu"],
|
||||
});
|
||||
expect(applied.map((g) => g.labelKey)).toEqual(["overview", "content"]);
|
||||
expect(applied[0]?.items.map((i) => i.href)).toEqual([
|
||||
"/admin",
|
||||
"/admin/menu",
|
||||
]);
|
||||
expect(applied[1]?.items.map((i) => i.href)).toEqual(["/admin/articles"]);
|
||||
expect(ADMIN_NAV_PINNED_HREFS.has("/admin")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips serialize → parse", () => {
|
||||
const raw = serializeAdminNavConfig({
|
||||
groupOrder: ["content"],
|
||||
hiddenGroups: ["system"],
|
||||
hiddenItems: ["/admin/photos", "/admin"],
|
||||
itemOrder: { content: ["/admin/articles"] },
|
||||
});
|
||||
const parsed = parseAdminNavConfig(raw);
|
||||
expect(parsed.groupOrder).toEqual(["content"]);
|
||||
expect(parsed.hiddenGroups).toEqual(["system"]);
|
||||
expect(parsed.hiddenItems).toEqual(["/admin/photos"]);
|
||||
expect(parsed.itemOrder).toEqual({ content: ["/admin/articles"] });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -18,6 +18,7 @@ import {
|
||||
KeyRound,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
ListOrdered,
|
||||
Megaphone,
|
||||
Monitor,
|
||||
Newspaper,
|
||||
@@ -528,6 +529,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
icon: Cog,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/menu",
|
||||
labelKey: "adminMenu",
|
||||
icon: ListOrdered,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/theme",
|
||||
labelKey: "theme",
|
||||
|
||||
@@ -58,6 +58,20 @@ describe("staff smoke contract", () => {
|
||||
expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("ships dynamic admin menu overlay", () => {
|
||||
expect(existsSync("src/lib/admin-nav-config.ts")).toBe(true);
|
||||
expect(existsSync("src/app/admin/menu/page.tsx")).toBe(true);
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||
"/admin/menu",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
|
||||
).toContain("applyAdminNavConfig");
|
||||
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
|
||||
"ADMIN_NAV_CONFIG_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps force-dynamic only on admin/mod layouts", () => {
|
||||
const hits: string[] = [];
|
||||
function walk(dir: string) {
|
||||
|
||||
@@ -1046,6 +1046,7 @@
|
||||
"translations": "Translations",
|
||||
"import": "Import",
|
||||
"settings": "Settings",
|
||||
"adminMenu": "Admin menu",
|
||||
"backToSite": "Back to site",
|
||||
"expandAll": "Expand all",
|
||||
"collapseAll": "Collapse all",
|
||||
@@ -2971,6 +2972,24 @@
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"title": "Admin menu",
|
||||
"subtitle": "Reorder and hide sidebar groups and links. Routes and permissions stay in code.",
|
||||
"save": "Save menu",
|
||||
"saving": "Saving…",
|
||||
"saved": "Admin menu saved.",
|
||||
"saveFailed": "Could not save admin menu.",
|
||||
"reset": "Reset to default",
|
||||
"viewOnly": "You can view the menu layout but need settings edit permission to change it.",
|
||||
"hint": "Dashboard and this page stay visible. ACL still filters what each rank sees.",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"hideGroup": "Hide group",
|
||||
"showGroup": "Show group",
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"analytics": {
|
||||
"loadError": "Some analytics queries failed. Numbers below may be incomplete — check the database connection and emulator tables.",
|
||||
"totalUsers": "Total users",
|
||||
|
||||
@@ -1010,6 +1010,7 @@
|
||||
"translations": "Traduzioni",
|
||||
"import": "Importa",
|
||||
"settings": "Impostazioni",
|
||||
"adminMenu": "Menu admin",
|
||||
"backToSite": "Torna al sito",
|
||||
"expandAll": "Apri tutte",
|
||||
"collapseAll": "Chiudi tutte",
|
||||
@@ -2853,6 +2854,24 @@
|
||||
"cancel": "Annulla"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"title": "Menu admin",
|
||||
"subtitle": "Riordina e nascondi gruppi e voci della sidebar. Route e permessi restano nel codice.",
|
||||
"save": "Salva menu",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Menu admin salvato.",
|
||||
"saveFailed": "Impossibile salvare il menu admin.",
|
||||
"reset": "Ripristina default",
|
||||
"viewOnly": "Puoi vedere il layout del menu ma serve il permesso di modifica impostazioni.",
|
||||
"hint": "Dashboard e questa pagina restano visibili. L'ACL continua a filtrare per grado.",
|
||||
"moveUp": "Sposta su",
|
||||
"moveDown": "Sposta giù",
|
||||
"hideGroup": "Nascondi gruppo",
|
||||
"showGroup": "Mostra gruppo",
|
||||
"hideItem": "Nascondi voce",
|
||||
"showItem": "Mostra voce",
|
||||
"pinned": "Fisso"
|
||||
},
|
||||
"favicon": {
|
||||
"title": "Favicon",
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
|
||||
Reference in new issue
Block a user