feat(admin): reorganize housekeeping and strengthen shared workflows
This commit is contained in:
1 parent
35f66d879f
commit
a070004e7b
35 files changed
+1667
-462
No files matched your search
+13
-1
@@ -212,8 +212,13 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
icon: Server,
|
||||
prefixes: ["/admin/devops"],
|
||||
tabs: [
|
||||
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] },
|
||||
{
|
||||
href: "/admin/devops",
|
||||
labelKey: "devops",
|
||||
match: ["/admin/devops"],
|
||||
},
|
||||
{ href: "/admin/devops/errors", labelKey: "errors" },
|
||||
{ href: "/admin/devops/cms-errors", labelKey: "cmsErrors" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -665,6 +670,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
icon: Server,
|
||||
permission: PERMS.DEVOPS_VIEW,
|
||||
matchPrefixes: ["/admin/devops"],
|
||||
matchExcludePrefixes: ["/admin/devops/cms-errors"],
|
||||
},
|
||||
{
|
||||
href: "/admin/devops/cms-errors",
|
||||
labelKey: "cmsErrors",
|
||||
icon: AlertTriangle,
|
||||
permission: PERMS.DEVOPS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/alerts",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||
import { parseFavoritePages, visibleAdminNavigation } from "./admin-navigation";
|
||||
|
||||
const access = { isSuperAdmin: true, allowedPermissions: [], navConfig: {} };
|
||||
it("retains each route and its permission definition once after regrouping", () => {
|
||||
const original = ADMIN_NAV_GROUPS.flatMap((g) => g.items);
|
||||
const actual = visibleAdminNavigation(access).flatMap((g) => g.items);
|
||||
expect(actual).toHaveLength(original.length);
|
||||
expect(new Set(actual.map((i) => i.href)).size).toBe(actual.length);
|
||||
for (const item of original) expect(actual).toContain(item);
|
||||
expect(
|
||||
visibleAdminNavigation(access)
|
||||
.find((g) => g.labelKey === "catalogFurniture")
|
||||
?.items.some((i) => i.href === "/admin/studio"),
|
||||
).toBe(true);
|
||||
});
|
||||
it("respects hidden groups, owner order and ACL in every consumer", () => {
|
||||
const configured = visibleAdminNavigation({
|
||||
...access,
|
||||
navConfig: {
|
||||
groupOrder: ["monitoring"],
|
||||
hiddenGroups: ["economy"],
|
||||
hiddenItems: ["/admin/studio"],
|
||||
},
|
||||
});
|
||||
expect(configured[0].labelKey).toBe("monitoring");
|
||||
expect(
|
||||
configured
|
||||
.flatMap((g) => g.items)
|
||||
.some((i) => i.href === "/admin/studio" || i.href === "/admin/catalog"),
|
||||
).toBe(false);
|
||||
const limited = visibleAdminNavigation({
|
||||
...access,
|
||||
isSuperAdmin: false,
|
||||
}).flatMap((g) => g.items);
|
||||
expect(limited.every((i) => !i.permission)).toBe(true);
|
||||
});
|
||||
it("rejects malformed favorites, external links and duplicates", () => {
|
||||
expect(parseFavoritePages("{")).toEqual([]);
|
||||
expect(
|
||||
parseFavoritePages(
|
||||
'["/admin/users","https://evil.test","/admin/users",null,"/admin?token=x"]',
|
||||
),
|
||||
).toEqual(["/admin/users"]);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Activity,
|
||||
Cog,
|
||||
LayoutDashboard,
|
||||
Newspaper,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ADMIN_NAV_GROUPS,
|
||||
type AdminNavGroup,
|
||||
navItemIsAllowed,
|
||||
} from "@/lib/admin-nav";
|
||||
import {
|
||||
type AdminNavConfig,
|
||||
applyAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
|
||||
export type AdminNavigationOptions = {
|
||||
allowedPermissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
navConfig: AdminNavConfig;
|
||||
};
|
||||
const sections = [
|
||||
{ labelKey: "overview", icon: LayoutDashboard, roots: [""] },
|
||||
{
|
||||
labelKey: "peopleSupport",
|
||||
icon: Users,
|
||||
roots: [
|
||||
"users",
|
||||
"online",
|
||||
"tickets",
|
||||
"help-tickets",
|
||||
"bans",
|
||||
"ip",
|
||||
"vpn",
|
||||
"wordfilter",
|
||||
"moderation",
|
||||
"rooms",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "catalogFurniture",
|
||||
icon: Package,
|
||||
roots: [
|
||||
"studio",
|
||||
"catalog",
|
||||
"import",
|
||||
"items",
|
||||
"rare-values",
|
||||
"badges",
|
||||
"achievements",
|
||||
"sounds",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "communityContent",
|
||||
icon: Newspaper,
|
||||
roots: [
|
||||
"articles",
|
||||
"photos",
|
||||
"events",
|
||||
"polls",
|
||||
"guilds",
|
||||
"media",
|
||||
"radio",
|
||||
"ads",
|
||||
"banners",
|
||||
"navigation",
|
||||
"help-questions",
|
||||
"writeable-boxes",
|
||||
"tags",
|
||||
"prefixes",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "economy",
|
||||
icon: ShoppingCart,
|
||||
roots: [
|
||||
"shop",
|
||||
"marketplace",
|
||||
"transactions",
|
||||
"vouchers",
|
||||
"subscriptions",
|
||||
"calendar",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "staffPermissions",
|
||||
icon: UsersRound,
|
||||
roots: ["teams", "applications", "permissions"],
|
||||
},
|
||||
{
|
||||
labelKey: "system",
|
||||
icon: Cog,
|
||||
roots: [
|
||||
"settings",
|
||||
"theme",
|
||||
"theme-builder",
|
||||
"maintenance",
|
||||
"favicon",
|
||||
"email-templates",
|
||||
"translations",
|
||||
"emulator",
|
||||
"housekeeping",
|
||||
"commandocentrum",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "monitoring",
|
||||
icon: Activity,
|
||||
roots: ["logs", "analytics", "devops", "alerts"],
|
||||
},
|
||||
];
|
||||
|
||||
/** Owner order takes precedence; hidden entries are filtered before regrouping. */
|
||||
export function visibleAdminNavigation(
|
||||
options: AdminNavigationOptions,
|
||||
): AdminNavGroup[] {
|
||||
const allowed = new Set(options.allowedPermissions);
|
||||
const groups = applyAdminNavConfig(ADMIN_NAV_GROUPS, options.navConfig)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) =>
|
||||
navItemIsAllowed(item, {
|
||||
isSuperAdmin: options.isSuperAdmin,
|
||||
has: (slug) => allowed.has(slug),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.items.length);
|
||||
if (
|
||||
options.navConfig.groupOrder?.length ||
|
||||
Object.values(options.navConfig.itemOrder ?? {}).some(
|
||||
(order) => order.length,
|
||||
)
|
||||
)
|
||||
return groups;
|
||||
const remaining = new Map(
|
||||
groups.flatMap((group) => group.items).map((item) => [item.href, item]),
|
||||
);
|
||||
const organized = sections
|
||||
.map((section) => {
|
||||
const items = [...remaining.values()].filter((item) =>
|
||||
section.roots.includes(item.href.split("/")[2] ?? ""),
|
||||
);
|
||||
for (const item of items) remaining.delete(item.href);
|
||||
return { labelKey: section.labelKey, icon: section.icon, items };
|
||||
})
|
||||
.filter((group) => group.items.length);
|
||||
// New routes remain discoverable even before being assigned to an activity.
|
||||
if (remaining.size)
|
||||
organized.push({
|
||||
labelKey: "tools",
|
||||
icon: Cog,
|
||||
items: [...remaining.values()],
|
||||
});
|
||||
return organized;
|
||||
}
|
||||
|
||||
export function parseFavoritePages(raw: string | null): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw ?? "[]");
|
||||
return Array.isArray(value)
|
||||
? [
|
||||
...new Set(
|
||||
value.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" &&
|
||||
/^\/admin(?:\/[^?#]*)?$/.test(entry),
|
||||
),
|
||||
),
|
||||
].slice(0, 6)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
allowed: new Set<string>(),
|
||||
queried: [] as unknown[],
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (_: unknown, handler: Function) => (request: unknown) =>
|
||||
handler(request, {
|
||||
session: { user: { id: 1, rank: 7 } },
|
||||
permissions: { isSuperAdmin: false },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
||||
vi.mock("@/lib/permissions", async () => ({
|
||||
...(await import("@/lib/permission-slugs")),
|
||||
canAccess: (_: unknown, slug: string) => state.allowed.has(slug),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const tables = Object.fromEntries(
|
||||
[
|
||||
"User",
|
||||
"Rooms",
|
||||
"Guilds",
|
||||
"WebsiteArticles",
|
||||
"WebsiteRareValues",
|
||||
"WebsiteShopArticles",
|
||||
].map((name) => [
|
||||
name,
|
||||
{
|
||||
name,
|
||||
id: `${name}Id`,
|
||||
username: `${name}Name`,
|
||||
mail: `${name}Mail`,
|
||||
slug: `${name}Slug`,
|
||||
ownerName: `${name}Owner`,
|
||||
itemId: `${name}Item`,
|
||||
description: `${name}Description`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
...tables,
|
||||
db: {
|
||||
select() {
|
||||
let table: unknown;
|
||||
const query = {
|
||||
from(value: unknown) {
|
||||
table = value;
|
||||
return query;
|
||||
},
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
async limit() {
|
||||
state.queried.push(table);
|
||||
return [{ id: 1, title: "Match", subtitle: "Data" }];
|
||||
},
|
||||
};
|
||||
return query;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "@/app/api/admin/search/route";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
beforeEach(() => {
|
||||
state.allowed.clear();
|
||||
state.queried.length = 0;
|
||||
});
|
||||
const request = () =>
|
||||
({
|
||||
nextUrl: new URL("https://test.invalid/api/admin/search?q=match"),
|
||||
}) as Parameters<typeof GET>[0];
|
||||
it("does not query or expose categories without their view permission", async () => {
|
||||
const response = await GET(request(), {});
|
||||
expect((await response.json()).results).toEqual([]);
|
||||
expect(state.queried).toHaveLength(0);
|
||||
});
|
||||
it("searches only the permitted category", async () => {
|
||||
state.allowed.add(PERMS.NEWS_VIEW);
|
||||
const response = await GET(request(), {});
|
||||
const body = await response.json();
|
||||
expect(body.results).toHaveLength(1);
|
||||
expect(body.results[0].type).toBe("articles");
|
||||
expect(state.queried).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { tablePageNumbers } from "./admin-table-state";
|
||||
|
||||
it.each([
|
||||
[1, 1],
|
||||
[1, 7],
|
||||
[6, 7],
|
||||
[7, 7],
|
||||
[50, 100],
|
||||
[100, 100],
|
||||
])("includes the current page %i/%i and stays in bounds", (page, last) => {
|
||||
const pages = tablePageNumbers(page, last);
|
||||
expect(pages).toContain(page);
|
||||
expect(pages.length).toBeLessThanOrEqual(5);
|
||||
expect(pages.every((p) => p >= 1 && p <= last)).toBe(true);
|
||||
expect(new Set(pages).size).toBe(pages.length);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/** A centered, bounded window including the current page, also at either end. */
|
||||
export function tablePageNumbers(page: number, last: number): number[] {
|
||||
const count = Math.min(5, Math.max(1, last));
|
||||
const start = Math.max(1, Math.min(page - 2, last - count + 1));
|
||||
return Array.from({ length: count }, (_, index) => start + index);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cache } from "react";
|
||||
import { collectNavPermissionSlugs } 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";
|
||||
|
||||
export const loadAdminNavigation = cache(async () => {
|
||||
const [{ session, permissions }, raw] = await Promise.all([
|
||||
getAdminContext(),
|
||||
siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||
]);
|
||||
const hasDashboard = canAccess(
|
||||
permissions,
|
||||
PERMS.ADMIN_DASHBOARD,
|
||||
session.user.rank,
|
||||
);
|
||||
return {
|
||||
isSuperAdmin: permissions.isSuperAdmin,
|
||||
allowedPermissions: permissions.isSuperAdmin
|
||||
? []
|
||||
: collectNavPermissionSlugs().filter(
|
||||
(slug) =>
|
||||
canAccess(permissions, slug, session.user.rank) ||
|
||||
(hasDashboard &&
|
||||
(slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD)),
|
||||
),
|
||||
navConfig: parseAdminNavConfig(raw),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { ImportJob } from "./import-job";
|
||||
import { retryableJobItems } from "./import-job-retry";
|
||||
|
||||
const job = (state: ImportJob["state"]) =>
|
||||
({
|
||||
state,
|
||||
items: ["done", "failed", "pending", "running"].map((state) => ({
|
||||
state,
|
||||
classname: state,
|
||||
})),
|
||||
}) as ImportJob;
|
||||
it("only retries failures after a completed job", () =>
|
||||
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
]));
|
||||
it("recovers all unfinished items from an interrupted job", () =>
|
||||
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
]));
|
||||
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
|
||||
expect(retryableJobItems(job(state))).toEqual([]),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ImportJob } from "./import-job";
|
||||
/** Never requeue an active job or furniture already imported successfully. */
|
||||
export function retryableJobItems(job: ImportJob): ImportJob["items"] {
|
||||
if (job.state === "running" || job.state === "queued") return [];
|
||||
return job.items.filter(
|
||||
(item) =>
|
||||
item.state === "failed" ||
|
||||
(job.state === "interrupted" && item.state !== "done"),
|
||||
);
|
||||
}
|
||||
@@ -81,10 +81,10 @@ describe("staff smoke contract", () => {
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain(
|
||||
"/admin/menu",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
|
||||
).toContain("applyAdminNavConfig");
|
||||
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
|
||||
expect(readFileSync("src/lib/admin-navigation.ts", "utf8")).toContain(
|
||||
"applyAdminNavConfig",
|
||||
);
|
||||
expect(readFileSync("src/lib/admin/navigation.ts", "utf8")).toContain(
|
||||
"ADMIN_NAV_CONFIG_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user