feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -33,12 +33,11 @@ describe("admin content module port", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("provides locale-free routes and matching server actions", () => {
|
||||
for (const module of ["events", "polls", "banners", "prefixes"]) {
|
||||
expect(existsSync(resolve(`src/app/admin/${module}/page.tsx`))).toBe(
|
||||
true,
|
||||
);
|
||||
it("retains public server actions after the ASE route cutover", () => {
|
||||
for (const module of ["events", "polls"]) {
|
||||
expect(existsSync(resolve(`src/actions/${module}.ts`))).toBe(true);
|
||||
}
|
||||
expect(existsSync(resolve("src/app/admin"))).toBe(false);
|
||||
expect(existsSync(resolve("src/app/ase/layout.tsx"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { calcPagination, parseListParams } from "./admin-helpers";
|
||||
import { canAccess, getAdminContext, type PermissionSet } from "./permissions";
|
||||
|
||||
interface AdminListConfig<TRow> {
|
||||
/** Permission slug required to view this page */
|
||||
permission: string;
|
||||
/** Default items per page. Default: 20 */
|
||||
defaultPerPage?: number;
|
||||
/**
|
||||
* Runs the actual data query (Drizzle). Receives parsed list params and
|
||||
* returns the rows + total count for the current page.
|
||||
*/
|
||||
fetch: (args: {
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
rawParams: Record<string, string>;
|
||||
}) => Promise<{ rows: TRow[]; total: number }>;
|
||||
}
|
||||
|
||||
interface AdminListResult<TRow> {
|
||||
rows: TRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
locale: string;
|
||||
permissions: PermissionSet;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic admin list data fetcher.
|
||||
* Centralizes the common pattern: auth check, param parsing, pagination and
|
||||
* row mapping. The caller supplies a Drizzle query via `config.fetch`.
|
||||
*/
|
||||
export async function fetchAdminList<TRow>(
|
||||
config: AdminListConfig<TRow>,
|
||||
paramsPromise: Promise<{ locale: string }>,
|
||||
searchParamsPromise: Promise<Record<string, string>>,
|
||||
): Promise<AdminListResult<TRow>> {
|
||||
const { locale } = await paramsPromise;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
|
||||
if (!canAccess(permissions, config.permission, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const rawParams = await searchParamsPromise;
|
||||
const sp = new URLSearchParams(rawParams);
|
||||
const parsed = parseListParams(sp);
|
||||
const perPage = config.defaultPerPage
|
||||
? Number(rawParams.perPage) || config.defaultPerPage
|
||||
: parsed.perPage;
|
||||
|
||||
const { rows, total } = await config.fetch({
|
||||
search: parsed.search,
|
||||
page: parsed.page,
|
||||
perPage,
|
||||
rawParams,
|
||||
});
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, perPage);
|
||||
|
||||
return {
|
||||
rows,
|
||||
...pagination,
|
||||
locale,
|
||||
permissions,
|
||||
rank: session.user.rank,
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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"] });
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { AdminNavGroup } from "@/lib/admin-nav";
|
||||
|
||||
/** website_settings key storing JSON overlay for the admin sidebar. */
|
||||
export const ADMIN_NAV_CONFIG_KEY = "admin_nav_config";
|
||||
|
||||
/** Hrefs that cannot be hidden (dashboard + menu editor). */
|
||||
export const ADMIN_NAV_PINNED_HREFS = new Set(["/admin", "/admin/menu"]);
|
||||
|
||||
export type AdminNavConfig = {
|
||||
/** Group labelKeys in display order. Missing groups append in catalog order. */
|
||||
groupOrder?: string[];
|
||||
/** Group labelKeys fully hidden from the sidebar. */
|
||||
hiddenGroups?: string[];
|
||||
/** Item hrefs hidden from the sidebar (except pinned). */
|
||||
hiddenItems?: string[];
|
||||
/** Per-group item href order. Missing items append in catalog order. */
|
||||
itemOrder?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function isStringArray(v: unknown): v is string[] {
|
||||
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
||||
}
|
||||
|
||||
/** Parse stored JSON; invalid / empty → empty config. */
|
||||
export function parseAdminNavConfig(
|
||||
raw: string | null | undefined,
|
||||
): AdminNavConfig {
|
||||
if (!raw?.trim()) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
const o = parsed as Record<string, unknown>;
|
||||
const config: AdminNavConfig = {};
|
||||
if (isStringArray(o.groupOrder)) config.groupOrder = o.groupOrder;
|
||||
if (isStringArray(o.hiddenGroups)) config.hiddenGroups = o.hiddenGroups;
|
||||
if (isStringArray(o.hiddenItems)) config.hiddenItems = o.hiddenItems;
|
||||
if (
|
||||
o.itemOrder &&
|
||||
typeof o.itemOrder === "object" &&
|
||||
!Array.isArray(o.itemOrder)
|
||||
) {
|
||||
const itemOrder: Record<string, string[]> = {};
|
||||
for (const [k, v] of Object.entries(
|
||||
o.itemOrder as Record<string, unknown>,
|
||||
)) {
|
||||
if (isStringArray(v)) itemOrder[k] = v;
|
||||
}
|
||||
config.itemOrder = itemOrder;
|
||||
}
|
||||
return config;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function orderByKeys<T>(
|
||||
items: T[],
|
||||
order: string[] | undefined,
|
||||
keyOf: (item: T) => string,
|
||||
): T[] {
|
||||
if (!order?.length) return items;
|
||||
const byKey = new Map(items.map((item) => [keyOf(item), item]));
|
||||
const seen = new Set<string>();
|
||||
const out: T[] = [];
|
||||
for (const key of order) {
|
||||
const hit = byKey.get(key);
|
||||
if (!hit || seen.has(key)) continue;
|
||||
out.push(hit);
|
||||
seen.add(key);
|
||||
}
|
||||
for (const item of items) {
|
||||
const key = keyOf(item);
|
||||
if (seen.has(key)) continue;
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply owner overlay (hide + reorder) on top of the code catalog.
|
||||
* Does not apply ACL — callers filter permissions afterwards.
|
||||
*/
|
||||
export function applyAdminNavConfig(
|
||||
groups: AdminNavGroup[],
|
||||
config: AdminNavConfig,
|
||||
): AdminNavGroup[] {
|
||||
const hiddenGroups = new Set(config.hiddenGroups ?? []);
|
||||
const hiddenItems = new Set(config.hiddenItems ?? []);
|
||||
|
||||
const mapped = groups
|
||||
.filter((g) => !hiddenGroups.has(g.labelKey))
|
||||
.map((group) => {
|
||||
const items = group.items.filter((item) => {
|
||||
if (ADMIN_NAV_PINNED_HREFS.has(item.href)) return true;
|
||||
return !hiddenItems.has(item.href);
|
||||
});
|
||||
const orderedItems = orderByKeys(
|
||||
items,
|
||||
config.itemOrder?.[group.labelKey],
|
||||
(item) => item.href,
|
||||
);
|
||||
return { ...group, items: orderedItems };
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
return orderByKeys(mapped, config.groupOrder, (g) => g.labelKey);
|
||||
}
|
||||
|
||||
export function serializeAdminNavConfig(config: AdminNavConfig): string {
|
||||
return JSON.stringify({
|
||||
groupOrder: config.groupOrder ?? [],
|
||||
hiddenGroups: config.hiddenGroups ?? [],
|
||||
hiddenItems: (config.hiddenItems ?? []).filter(
|
||||
(href) => !ADMIN_NAV_PINNED_HREFS.has(href),
|
||||
),
|
||||
itemOrder: config.itemOrder ?? {},
|
||||
});
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Calendar } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADMIN_NAV_GROUPS,
|
||||
collectNavPermissionSlugs,
|
||||
findAdminHub,
|
||||
navItemIsAllowed,
|
||||
} from "./admin-nav";
|
||||
|
||||
describe("findAdminHub", () => {
|
||||
it("matches by prefix", () => {
|
||||
expect(findAdminHub("/admin/events")?.id).toBe("events");
|
||||
expect(findAdminHub("/admin/events/123")?.id).toBe("events");
|
||||
});
|
||||
it("uses longest-prefix match", () => {
|
||||
expect(findAdminHub("/admin/tickets/desk")?.id).toBe("tickets");
|
||||
});
|
||||
it("returns null for non-hub paths", () => {
|
||||
expect(findAdminHub("/admin/users")).toBeNull();
|
||||
expect(findAdminHub("/login")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("navItemIsAllowed", () => {
|
||||
const item = {
|
||||
href: "/admin/x",
|
||||
labelKey: "x",
|
||||
icon: Calendar,
|
||||
permission: "a.view",
|
||||
};
|
||||
|
||||
it("always allows for super admins", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, { isSuperAdmin: true, has: () => false }),
|
||||
).toBe(true);
|
||||
});
|
||||
it("allows when any needed slug is granted", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, {
|
||||
isSuperAdmin: false,
|
||||
has: (s) => s === "a.view",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it("denies when no slug is granted", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, { isSuperAdmin: false, has: () => false }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("allows items without a permission requirement", () => {
|
||||
expect(
|
||||
navItemIsAllowed(
|
||||
{ href: "/admin/d", labelKey: "d", icon: Calendar },
|
||||
{ isSuperAdmin: false, has: () => false },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it("supports an array of permissions (any match)", () => {
|
||||
const multi = {
|
||||
href: "/admin/m",
|
||||
labelKey: "m",
|
||||
icon: Calendar,
|
||||
permission: ["x.view", "y.view"],
|
||||
};
|
||||
expect(
|
||||
navItemIsAllowed(multi, {
|
||||
isSuperAdmin: false,
|
||||
has: (s) => s === "y.view",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectNavPermissionSlugs", () => {
|
||||
it("returns unique slugs referenced by the sidebar", () => {
|
||||
const slugs = collectNavPermissionSlugs();
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
expect(slugs.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("is consistent with the nav groups", () => {
|
||||
const groupSlugs = new Set<string>();
|
||||
for (const group of ADMIN_NAV_GROUPS) {
|
||||
for (const item of group.items) {
|
||||
if (!item.permission) continue;
|
||||
const needed = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
for (const s of needed) groupSlugs.add(s);
|
||||
}
|
||||
}
|
||||
expect([...groupSlugs]).toEqual(collectNavPermissionSlugs());
|
||||
});
|
||||
});
|
||||
@@ -1,706 +0,0 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
Cog,
|
||||
Compass,
|
||||
FileText,
|
||||
Filter,
|
||||
Gavel,
|
||||
HelpCircle,
|
||||
Image,
|
||||
KeyRound,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
ListOrdered,
|
||||
type LucideIcon,
|
||||
Megaphone,
|
||||
Monitor,
|
||||
Newspaper,
|
||||
Package,
|
||||
Palette,
|
||||
Radio,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
Store,
|
||||
Tags,
|
||||
Terminal,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Users,
|
||||
UsersRound,
|
||||
Volume2,
|
||||
Vote,
|
||||
Wifi,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
export interface AdminHubTab {
|
||||
href: string;
|
||||
/** Key under pages.admin.hubs.tabs.* */
|
||||
labelKey: string;
|
||||
match?: string[];
|
||||
/** Optional group id for multi-row tab strips (e.g. Radio primary/tools). */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface AdminHubDefinition {
|
||||
id: string;
|
||||
/** Key under pages.admin.hubs.* for title/subtitle */
|
||||
titleKey: string;
|
||||
subtitleKey: string;
|
||||
icon: LucideIcon;
|
||||
/** Path prefixes belonging to this hub (for chrome + sidebar active). */
|
||||
prefixes: string[];
|
||||
tabs: AdminHubTab[];
|
||||
}
|
||||
|
||||
export interface AdminNavItem {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
/**
|
||||
* ACL slug(s) required to show this item. Any match is enough.
|
||||
* Omit only for the dashboard (already gated by admin.dashboard).
|
||||
*/
|
||||
permission?: string | readonly string[];
|
||||
/** When set, sidebar item is active if pathname matches any prefix. */
|
||||
matchPrefixes?: string[];
|
||||
/** Prefixes that must NOT count as active (e.g. /admin/users vs multi-accounts). */
|
||||
matchExcludePrefixes?: string[];
|
||||
}
|
||||
|
||||
export interface AdminNavGroup {
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
items: AdminNavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hubs with real sibling tabs only. Standalone features have no hub chrome
|
||||
* (sidebar links go straight to the page).
|
||||
*/
|
||||
export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
{
|
||||
id: "events",
|
||||
titleKey: "events",
|
||||
subtitleKey: "eventsSubtitle",
|
||||
icon: Calendar,
|
||||
prefixes: ["/admin/events"],
|
||||
tabs: [
|
||||
{ href: "/admin/events", labelKey: "events", match: ["/admin/events"] },
|
||||
{ href: "/admin/events/types", labelKey: "eventTypes" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tickets",
|
||||
titleKey: "tickets",
|
||||
subtitleKey: "ticketsSubtitle",
|
||||
icon: Ticket,
|
||||
prefixes: ["/admin/tickets", "/admin/help-tickets"],
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
labelKey: "ticketInbox",
|
||||
match: ["/admin/tickets"],
|
||||
},
|
||||
{
|
||||
href: "/admin/tickets/desk",
|
||||
labelKey: "tickets",
|
||||
match: ["/admin/tickets/desk"],
|
||||
},
|
||||
{
|
||||
href: "/admin/help-tickets",
|
||||
labelKey: "helpTickets",
|
||||
match: ["/admin/help-tickets"],
|
||||
},
|
||||
{ href: "/admin/tickets/templates", labelKey: "templates" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "staff-access",
|
||||
titleKey: "staffAccess",
|
||||
subtitleKey: "staffAccessSubtitle",
|
||||
icon: KeyRound,
|
||||
prefixes: ["/admin/teams", "/admin/permissions", "/admin/housekeeping"],
|
||||
tabs: [
|
||||
{ href: "/admin/teams", labelKey: "teams" },
|
||||
{
|
||||
href: "/admin/permissions",
|
||||
labelKey: "permissions",
|
||||
match: ["/admin/permissions"],
|
||||
},
|
||||
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "moderation",
|
||||
titleKey: "moderation",
|
||||
subtitleKey: "moderationSubtitle",
|
||||
icon: Gavel,
|
||||
prefixes: ["/admin/moderation"],
|
||||
tabs: [
|
||||
{ href: "/admin/moderation", labelKey: "overview" },
|
||||
{ href: "/admin/moderation/actions", labelKey: "actions" },
|
||||
{
|
||||
href: "/admin/moderation/cfh",
|
||||
labelKey: "cfh",
|
||||
match: ["/admin/moderation/cfh"],
|
||||
},
|
||||
{ href: "/admin/moderation/team", labelKey: "team" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "radio",
|
||||
titleKey: "radio",
|
||||
subtitleKey: "radioSubtitle",
|
||||
icon: Radio,
|
||||
prefixes: ["/admin/radio"],
|
||||
tabs: [
|
||||
{ href: "/admin/radio", labelKey: "overview", group: "primary" },
|
||||
{ href: "/admin/radio/history", labelKey: "history", group: "primary" },
|
||||
{
|
||||
href: "/admin/radio/moderation",
|
||||
labelKey: "moderationTab",
|
||||
group: "primary",
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/monitoring",
|
||||
labelKey: "monitoring",
|
||||
group: "primary",
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/settings",
|
||||
labelKey: "settingsTab",
|
||||
group: "primary",
|
||||
},
|
||||
{ href: "/admin/radio/banners", labelKey: "banners", group: "tools" },
|
||||
{ href: "/admin/radio/embed", labelKey: "embed", group: "tools" },
|
||||
{ href: "/admin/radio/api-keys", labelKey: "apiKeys", group: "tools" },
|
||||
{ href: "/admin/radio/points", labelKey: "points", group: "tools" },
|
||||
{ href: "/admin/radio/ranks", labelKey: "ranks", group: "tools" },
|
||||
{ href: "/admin/radio/autodj", labelKey: "autoDj", group: "tools" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
titleKey: "analytics",
|
||||
subtitleKey: "analyticsSubtitle",
|
||||
icon: Activity,
|
||||
prefixes: ["/admin/analytics"],
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/analytics",
|
||||
labelKey: "analytics",
|
||||
match: ["/admin/analytics"],
|
||||
},
|
||||
{ href: "/admin/analytics/activity", labelKey: "activity" },
|
||||
{ href: "/admin/analytics/economy", labelKey: "economy" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "devops",
|
||||
titleKey: "devops",
|
||||
subtitleKey: "devopsSubtitle",
|
||||
icon: Server,
|
||||
prefixes: ["/admin/devops"],
|
||||
tabs: [
|
||||
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] },
|
||||
{ href: "/admin/devops/errors", labelKey: "errors" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */
|
||||
export function findAdminHub(pathname: string): AdminHubDefinition | null {
|
||||
let best: AdminHubDefinition | null = null;
|
||||
let bestLen = -1;
|
||||
for (const hub of ADMIN_HUBS) {
|
||||
for (const prefix of hub.prefixes) {
|
||||
const hit = pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||
if (hit && prefix.length > bestLen) {
|
||||
best = hub;
|
||||
bestLen = prefix.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Feature-first sidebar: one entry per feature; hubs only for sibling tabs. */
|
||||
export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
{
|
||||
labelKey: "overview",
|
||||
icon: LayoutDashboard,
|
||||
items: [
|
||||
{
|
||||
href: "/admin",
|
||||
labelKey: "dashboard",
|
||||
icon: LayoutDashboard,
|
||||
permission: PERMS.ADMIN_DASHBOARD,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "content",
|
||||
icon: Newspaper,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/articles",
|
||||
labelKey: "articles",
|
||||
icon: Newspaper,
|
||||
permission: PERMS.NEWS_VIEW,
|
||||
matchPrefixes: ["/admin/articles"],
|
||||
},
|
||||
{
|
||||
href: "/admin/photos",
|
||||
labelKey: "photos",
|
||||
icon: Image,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/banners",
|
||||
labelKey: "banners",
|
||||
icon: Megaphone,
|
||||
permission: PERMS.BANNERS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/ads",
|
||||
labelKey: "advertisements",
|
||||
icon: FileText,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
matchPrefixes: ["/admin/ads"],
|
||||
},
|
||||
{
|
||||
href: "/admin/media",
|
||||
labelKey: "media",
|
||||
icon: Image,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/navigation",
|
||||
labelKey: "navigator",
|
||||
icon: Compass,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/help-questions",
|
||||
labelKey: "helpCenter",
|
||||
icon: HelpCircle,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
matchPrefixes: ["/admin/help-questions"],
|
||||
},
|
||||
{
|
||||
href: "/admin/writeable-boxes",
|
||||
labelKey: "writeableBoxes",
|
||||
icon: Package,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/tags",
|
||||
labelKey: "tags",
|
||||
icon: Tags,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/prefixes",
|
||||
labelKey: "prefixes",
|
||||
icon: Sparkles,
|
||||
permission: PERMS.PREFIXES_VIEW,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "community",
|
||||
icon: Calendar,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/events",
|
||||
labelKey: "events",
|
||||
icon: Calendar,
|
||||
permission: PERMS.EVENTS_VIEW,
|
||||
matchPrefixes: ["/admin/events"],
|
||||
},
|
||||
{
|
||||
href: "/admin/polls",
|
||||
labelKey: "polls",
|
||||
icon: Vote,
|
||||
permission: PERMS.POLLS_VIEW,
|
||||
matchPrefixes: ["/admin/polls"],
|
||||
},
|
||||
{
|
||||
href: "/admin/guilds",
|
||||
labelKey: "guilds",
|
||||
icon: UsersRound,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
matchPrefixes: ["/admin/guilds"],
|
||||
},
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
labelKey: "tickets",
|
||||
icon: Ticket,
|
||||
permission: PERMS.TICKETS_VIEW,
|
||||
matchPrefixes: ["/admin/tickets", "/admin/help-tickets"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "usersAndAccess",
|
||||
icon: Users,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/users",
|
||||
labelKey: "users",
|
||||
icon: Users,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
matchPrefixes: ["/admin/users"],
|
||||
matchExcludePrefixes: ["/admin/users/multi-accounts"],
|
||||
},
|
||||
{
|
||||
href: "/admin/users/multi-accounts",
|
||||
labelKey: "multiAccounts",
|
||||
icon: Users,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/online",
|
||||
labelKey: "onlineUsers",
|
||||
icon: Wifi,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/applications",
|
||||
labelKey: "applications",
|
||||
icon: ClipboardList,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/teams",
|
||||
labelKey: "staffAccess",
|
||||
icon: KeyRound,
|
||||
permission: [
|
||||
PERMS.USERS_VIEW,
|
||||
PERMS.PERMISSIONS_MANAGE,
|
||||
PERMS.SETTINGS_VIEW,
|
||||
],
|
||||
matchPrefixes: [
|
||||
"/admin/teams",
|
||||
"/admin/permissions",
|
||||
"/admin/housekeeping",
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/bans",
|
||||
labelKey: "bans",
|
||||
icon: Ban,
|
||||
permission: PERMS.BANS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/ip",
|
||||
labelKey: "ipManagement",
|
||||
icon: Shield,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/vpn",
|
||||
labelKey: "vpn",
|
||||
icon: Shield,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/wordfilter",
|
||||
labelKey: "wordFilter",
|
||||
icon: Filter,
|
||||
permission: PERMS.WORDFILTER_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/moderation",
|
||||
labelKey: "moderation",
|
||||
icon: Gavel,
|
||||
permission: PERMS.MODERATION_VIEW,
|
||||
matchPrefixes: ["/admin/moderation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "economy",
|
||||
icon: ShoppingCart,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/catalog",
|
||||
labelKey: "catalog",
|
||||
icon: Store,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
matchPrefixes: ["/admin/catalog"],
|
||||
},
|
||||
{
|
||||
href: "/admin/catalog/maintenance",
|
||||
labelKey: "catalogMaintenance",
|
||||
icon: Wrench,
|
||||
permission: PERMS.CATALOG_EDIT,
|
||||
matchPrefixes: ["/admin/catalog/maintenance"],
|
||||
},
|
||||
{
|
||||
href: "/admin/items",
|
||||
labelKey: "itemsBase",
|
||||
icon: Package,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
matchPrefixes: ["/admin/items"],
|
||||
},
|
||||
{
|
||||
href: "/admin/rare-values",
|
||||
labelKey: "rareValues",
|
||||
icon: Sparkles,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/badges",
|
||||
labelKey: "badges",
|
||||
icon: BadgeCheck,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/achievements",
|
||||
labelKey: "achievements",
|
||||
icon: Trophy,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/sounds",
|
||||
labelKey: "sounds",
|
||||
icon: Volume2,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/shop",
|
||||
labelKey: "shop",
|
||||
icon: ShoppingCart,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/marketplace",
|
||||
labelKey: "marketplace",
|
||||
icon: Store,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/transactions",
|
||||
labelKey: "transactions",
|
||||
icon: Activity,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/vouchers",
|
||||
labelKey: "vouchers",
|
||||
icon: Ticket,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/subscriptions",
|
||||
labelKey: "subscriptions",
|
||||
icon: ClipboardList,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/calendar",
|
||||
labelKey: "calendar",
|
||||
icon: CalendarDays,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
matchPrefixes: ["/admin/calendar"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "radio",
|
||||
icon: Radio,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/radio",
|
||||
labelKey: "radio",
|
||||
icon: Radio,
|
||||
permission: PERMS.RADIO_VIEW,
|
||||
matchPrefixes: ["/admin/radio"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "system",
|
||||
icon: Settings,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/settings",
|
||||
labelKey: "settings",
|
||||
icon: Cog,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/menu",
|
||||
labelKey: "adminMenu",
|
||||
icon: ListOrdered,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/theme",
|
||||
labelKey: "theme",
|
||||
icon: Sparkles,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/maintenance",
|
||||
labelKey: "maintenance",
|
||||
icon: Wrench,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/favicon",
|
||||
labelKey: "favicon",
|
||||
icon: Image,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/emulator",
|
||||
labelKey: "emulator",
|
||||
icon: Server,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/email-templates",
|
||||
labelKey: "emailTemplates",
|
||||
icon: FileText,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "tools",
|
||||
icon: Wrench,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/commandocentrum",
|
||||
labelKey: "commandocentrum",
|
||||
icon: Terminal,
|
||||
permission: PERMS.RCON_EXECUTE,
|
||||
},
|
||||
{
|
||||
href: "/admin/rooms",
|
||||
labelKey: "rooms",
|
||||
icon: Store,
|
||||
permission: PERMS.ROOMS_VIEW,
|
||||
matchPrefixes: ["/admin/rooms"],
|
||||
},
|
||||
{
|
||||
href: "/admin/studio",
|
||||
labelKey: "studio",
|
||||
icon: Palette,
|
||||
permission: PERMS.ASSETS_IMPORT,
|
||||
matchPrefixes: ["/admin/studio"],
|
||||
},
|
||||
{
|
||||
href: "/admin/translations",
|
||||
labelKey: "translations",
|
||||
icon: Languages,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
matchPrefixes: ["/admin/translations"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "monitoring",
|
||||
icon: Monitor,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/logs",
|
||||
labelKey: "logs",
|
||||
icon: FileText,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
matchPrefixes: ["/admin/logs"],
|
||||
matchExcludePrefixes: [
|
||||
"/admin/logs/audit",
|
||||
"/admin/logs/chat",
|
||||
"/admin/logs/commands",
|
||||
"/admin/logs/trades",
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/logs/audit",
|
||||
labelKey: "auditLog",
|
||||
icon: ClipboardList,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/logs/chat",
|
||||
labelKey: "chatLog",
|
||||
icon: FileText,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/logs/commands",
|
||||
labelKey: "commandLog",
|
||||
icon: Terminal,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/logs/trades",
|
||||
labelKey: "tradeLog",
|
||||
icon: Activity,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/analytics",
|
||||
labelKey: "analytics",
|
||||
icon: Activity,
|
||||
permission: PERMS.ANALYTICS_VIEW,
|
||||
matchPrefixes: ["/admin/analytics"],
|
||||
},
|
||||
{
|
||||
href: "/admin/devops",
|
||||
labelKey: "devops",
|
||||
icon: Server,
|
||||
permission: PERMS.DEVOPS_VIEW,
|
||||
matchPrefixes: ["/admin/devops"],
|
||||
},
|
||||
{
|
||||
href: "/admin/alerts",
|
||||
labelKey: "alerts",
|
||||
icon: AlertTriangle,
|
||||
permission: PERMS.NOTIFICATIONS_VIEW,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Whether a nav item should be visible for the given permission set. */
|
||||
export function navItemIsAllowed(
|
||||
item: AdminNavItem,
|
||||
opts: { isSuperAdmin: boolean; has: (slug: string) => boolean },
|
||||
): boolean {
|
||||
if (opts.isSuperAdmin) return true;
|
||||
if (!item.permission) return true;
|
||||
const needed = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
return needed.some((slug) => opts.has(slug));
|
||||
}
|
||||
|
||||
/** Collect every ACL slug referenced by the sidebar (for layout gating). */
|
||||
export function collectNavPermissionSlugs(): string[] {
|
||||
const slugs = new Set<string>();
|
||||
for (const group of ADMIN_NAV_GROUPS) {
|
||||
for (const item of group.items) {
|
||||
if (!item.permission) continue;
|
||||
const needed = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
for (const slug of needed) slugs.add(slug);
|
||||
}
|
||||
}
|
||||
return [...slugs];
|
||||
}
|
||||
@@ -2,78 +2,16 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROUTES: Array<[string, string]> = [
|
||||
["moderation", "PERMS.MODERATION_VIEW"],
|
||||
["moderation/actions", "PERMS.MODERATION_EDIT"],
|
||||
["moderation/cfh", "PERMS.MODERATION_VIEW"],
|
||||
["logs/audit", "PERMS.LOGS_VIEW"],
|
||||
["analytics", "PERMS.ANALYTICS_VIEW"],
|
||||
["devops", "PERMS.DEVOPS_VIEW"],
|
||||
["online", "PERMS.USERS_VIEW"],
|
||||
["commandocentrum", "PERMS.RCON_EXECUTE"],
|
||||
["users/edit/[id]", "PERMS.USERS_EDIT"],
|
||||
["settings", "PERMS.SETTINGS_VIEW"],
|
||||
["theme", "PERMS.SETTINGS_VIEW"],
|
||||
["emulator", "PERMS.SETTINGS_VIEW"],
|
||||
["bans", "PERMS.BANS_VIEW"],
|
||||
["wordfilter", "PERMS.WORDFILTER_VIEW"],
|
||||
["articles", "PERMS.NEWS_VIEW"],
|
||||
["shop", "PERMS.SHOP_VIEW"],
|
||||
["transactions", "PERMS.SHOP_VIEW"],
|
||||
["vouchers", "PERMS.SHOP_VIEW"],
|
||||
["marketplace", "PERMS.SHOP_VIEW"],
|
||||
["vpn", "PERMS.SETTINGS_VIEW"],
|
||||
["ip", "PERMS.SETTINGS_VIEW"],
|
||||
["maintenance", "PERMS.SETTINGS_VIEW"],
|
||||
["alerts", "PERMS.NOTIFICATIONS_VIEW"],
|
||||
["tags", "PERMS.PAGES_VIEW"],
|
||||
["ads", "PERMS.PAGES_VIEW"],
|
||||
["media", "PERMS.PAGES_VIEW"],
|
||||
["photos", "PERMS.PAGES_VIEW"],
|
||||
["badges", "PERMS.CATALOG_VIEW"],
|
||||
["teams", "PERMS.USERS_VIEW"],
|
||||
["applications", "PERMS.USERS_VIEW"],
|
||||
["guilds", "PERMS.USERS_VIEW"],
|
||||
["tickets", "PERMS.TICKETS_VIEW"],
|
||||
["help-tickets", "PERMS.TICKETS_VIEW"],
|
||||
["permissions", "PERMS.PERMISSIONS_MANAGE"],
|
||||
["housekeeping", "PERMS.SETTINGS_VIEW"],
|
||||
["logs", "PERMS.LOGS_VIEW"],
|
||||
["catalog", "PERMS.CATALOG_VIEW"],
|
||||
["items", "PERMS.CATALOG_VIEW"],
|
||||
["items/[id]", "PERMS.CATALOG_VIEW"],
|
||||
["rooms/edit/[id]", "PERMS.ROOMS_EDIT"],
|
||||
];
|
||||
|
||||
const MOD_ROUTES: Array<[string, string]> = [
|
||||
["", "requireMod"],
|
||||
["cfh", "PERMS.MOD_CFH_VIEW"],
|
||||
["actions", "PERMS.MOD_ACTIONS"],
|
||||
["bans", "PERMS.MOD_BANS_VIEW"],
|
||||
["tickets", "PERMS.MOD_TICKETS_VIEW"],
|
||||
["help-tickets", "PERMS.MOD_TICKETS_VIEW"],
|
||||
["users", "PERMS.MOD_USERS_VIEW"],
|
||||
["team", "PERMS.MOD_TEAM_VIEW"],
|
||||
];
|
||||
|
||||
const ACTION_GATES: Array<[string, string]> = [
|
||||
["admin-settings.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-theme.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-emulator.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-bans.ts", "PERMS.USERS_BAN"],
|
||||
["admin-wordfilter.ts", "PERMS.WORDFILTER_EDIT"],
|
||||
["admin-articles.ts", "PERMS.NEWS_EDIT"],
|
||||
["admin-shop.ts", "PERMS.SHOP_EDIT"],
|
||||
["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"],
|
||||
["catalog.ts", "PERMS.CATALOG_EDIT"],
|
||||
["items-base.ts", "PERMS.CATALOG_EDIT"],
|
||||
["rooms.ts", "PERMS.ROOMS_EDIT"],
|
||||
["admin-vpn.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-ads.ts", "PERMS.PAGES_EDIT"],
|
||||
["admin-vouchers.ts", "PERMS.SHOP_EDIT"],
|
||||
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
|
||||
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
||||
["translations.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["tickets.ts", "PERMS.TICKETS_EDIT"],
|
||||
["admin-help-tickets.ts", "PERMS.TICKETS_EDIT"],
|
||||
["permissions.ts", "PERMS.PERMISSIONS_MANAGE"],
|
||||
@@ -82,32 +20,7 @@ const ACTION_GATES: Array<[string, string]> = [
|
||||
["moderation.ts", "PERMS.MODERATION_EDIT"],
|
||||
];
|
||||
|
||||
describe("admin operations route contract", () => {
|
||||
it.each(ROUTES)("provides and guards /admin/%s", (route, permission) => {
|
||||
const path = `src/app/admin/${route}/page.tsx`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain(permission);
|
||||
});
|
||||
|
||||
it.each(MOD_ROUTES)("provides and guards /mod/%s", (route, permission) => {
|
||||
const path =
|
||||
route === "" ? "src/app/mod/page.tsx" : `src/app/mod/${route}/page.tsx`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
const source = readFileSync(path, "utf8");
|
||||
const layout = readFileSync("src/app/mod/layout.tsx", "utf8");
|
||||
if (permission === "requireMod") {
|
||||
expect(layout).toContain("requireMod");
|
||||
} else {
|
||||
expect(source).toContain(permission);
|
||||
}
|
||||
});
|
||||
|
||||
it("guards radio section via layout", () => {
|
||||
const path = "src/app/admin/radio/layout.tsx";
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain("PERMS.RADIO_VIEW");
|
||||
});
|
||||
|
||||
describe("administration backend contract", () => {
|
||||
it.each([
|
||||
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
|
||||
["devops/health", "PERMS.DEVOPS_VIEW"],
|
||||
@@ -138,24 +51,8 @@ describe("admin operations route contract", () => {
|
||||
expect(offenders).toEqual(["save-logo.ts"]);
|
||||
});
|
||||
|
||||
it("caches analytics full-scans via redisCache", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/analytics/page.tsx",
|
||||
"src/app/admin/analytics/activity/page.tsx",
|
||||
"src/app/admin/analytics/economy/page.tsx",
|
||||
]) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain("redisCache");
|
||||
}
|
||||
});
|
||||
|
||||
it("shares ops health probe across CC / DevOps / API", () => {
|
||||
it("keeps the shared ops health probe behind the API", () => {
|
||||
expect(existsSync("src/lib/admin/ops-health.ts")).toBe(true);
|
||||
expect(
|
||||
readFileSync("src/app/admin/commandocentrum/page.tsx", "utf8"),
|
||||
).toContain("fetchOpsHealth");
|
||||
expect(readFileSync("src/app/admin/devops/page.tsx", "utf8")).toContain(
|
||||
"fetchOpsHealth",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/app/api/admin/devops/health/route.ts", "utf8"),
|
||||
).toContain("fetchOpsHealth");
|
||||
|
||||
@@ -3,31 +3,16 @@ import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOTS = [
|
||||
"src/app/admin",
|
||||
"src/components/admin",
|
||||
"src/app/ase-next",
|
||||
"src/app/ase",
|
||||
"src/features/housekeeping",
|
||||
];
|
||||
const GRAPHICAL_ALLOWLIST = [
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/import/clone/import-clone-client.tsx",
|
||||
"src/components/admin/catalog/items-shop-preview.tsx",
|
||||
"src/components/admin/media-grid.tsx",
|
||||
];
|
||||
|
||||
const DATA_COLOR_ALLOWLIST = [
|
||||
"src/app/admin/alerts/page.tsx",
|
||||
"src/app/admin/banners/banners-manager.tsx",
|
||||
"src/app/admin/events/events-table.tsx",
|
||||
"src/app/admin/events/types/event-types-manager.tsx",
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/help-questions/new/page.tsx",
|
||||
"src/app/admin/help-questions/[id]/page.tsx",
|
||||
"src/app/admin/prefixes/prefixes-client.tsx",
|
||||
"src/app/admin/tags/page.tsx",
|
||||
"src/app/admin/teams/page.tsx",
|
||||
"src/app/admin/theme/page.tsx",
|
||||
];
|
||||
const DATA_COLOR_ALLOWLIST: readonly string[] = [];
|
||||
|
||||
const PUBLIC_STRUCTURAL_TOKEN =
|
||||
/var\(--(?:color-(?:background|surface|dropdown|navbar|navbar-text|text|text-muted|primary|primary-hover)|border-subtle|border-color)\)/g;
|
||||
@@ -91,17 +76,20 @@ describe("admin theme source audit", () => {
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every import workflow on semantic admin status and overlay colors", () => {
|
||||
const violations = sourceFiles("src/app/admin/import").flatMap((file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky =
|
||||
source.match(/rgba?\([^)]*\)|(?:bg-black|text-white)(?:\/\d+)?/g) ?? [];
|
||||
return risky.length
|
||||
? [
|
||||
`${relative(process.cwd(), file).replaceAll("\\", "/")}: ${[...new Set(risky)].join(", ")}`,
|
||||
]
|
||||
: [];
|
||||
});
|
||||
it("keeps retained Studio workflows on semantic status and overlay colors", () => {
|
||||
const violations = sourceFiles("src/components/admin/studio").flatMap(
|
||||
(file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky =
|
||||
source.match(/rgba?\([^)]*\)|(?:bg-black|text-white)(?:\/\d+)?/g) ??
|
||||
[];
|
||||
return risky.length
|
||||
? [
|
||||
`${relative(process.cwd(), file).replaceAll("\\", "/")}: ${[...new Set(risky)].join(", ")}`,
|
||||
]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
|
||||
|
||||
export type FieldType =
|
||||
| "text"
|
||||
| "password"
|
||||
| "url"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "textarea"
|
||||
| "select";
|
||||
|
||||
export type SettingsGroupIcon =
|
||||
| "building"
|
||||
| "image"
|
||||
| "gamepad"
|
||||
| "music"
|
||||
| "shield"
|
||||
| "link"
|
||||
| "user-plus";
|
||||
|
||||
export interface ManagedField {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type?: FieldType;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface SettingsGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: SettingsGroupIcon;
|
||||
description?: string;
|
||||
fields: ManagedField[];
|
||||
}
|
||||
|
||||
/** Keys managed by the structured CMS Settings form (not theme / VPN / maintenance pages). */
|
||||
export const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||
{
|
||||
id: "identity",
|
||||
title: "Hotel identity",
|
||||
icon: "building",
|
||||
description: "Name, branding and defaults shown across the site.",
|
||||
fields: [
|
||||
{
|
||||
key: "cms_logo",
|
||||
label: "Logo URL",
|
||||
description: "Header logo path or absolute URL.",
|
||||
type: "url",
|
||||
placeholder: "/api/media/logo/…",
|
||||
},
|
||||
{
|
||||
key: "cms_favicon",
|
||||
label: "Favicon URL",
|
||||
type: "url",
|
||||
placeholder: "/favicon.svg",
|
||||
},
|
||||
{
|
||||
key: "cms_header",
|
||||
label: "Header background",
|
||||
type: "url",
|
||||
placeholder: "/assets/images/background.png",
|
||||
defaultValue: "/assets/images/background.png",
|
||||
},
|
||||
{
|
||||
key: "default_dark",
|
||||
label: "Dark mode by default",
|
||||
description: "New visitors start in dark mode unless they override it.",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "min_staff_rank",
|
||||
label: "Minimum staff rank",
|
||||
description:
|
||||
"Minimum rank treated as staff (admin lists and public staff page).",
|
||||
type: "number",
|
||||
defaultValue: "7",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "imaging",
|
||||
title: "Avatars & badges",
|
||||
icon: "image",
|
||||
description: "Imaging endpoints used for avatars and badge icons.",
|
||||
fields: [
|
||||
{
|
||||
key: "habbo_imaging_url",
|
||||
label: "Public imager URL",
|
||||
type: "url",
|
||||
defaultValue: "/imaging",
|
||||
description:
|
||||
"Public avatar endpoint (relative or absolute). Leave as /imaging to serve from this site.",
|
||||
},
|
||||
{
|
||||
key: "imaging_use_habbo_fallback",
|
||||
label: "Use Habbo.com as avatar fallback (legacy proxy)",
|
||||
type: "boolean",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
key: "badge_base_url",
|
||||
label: "Badge icons base URL",
|
||||
type: "url",
|
||||
placeholder: "/swf/c_images/album1584",
|
||||
},
|
||||
{
|
||||
key: "badges_path",
|
||||
label: "Badges path (rares page)",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "furniture_icons_path",
|
||||
label: "Furniture icons path",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "client",
|
||||
title: "Nitro client",
|
||||
icon: "gamepad",
|
||||
description: "Game client loaded at /client.",
|
||||
fields: [
|
||||
{
|
||||
key: "nitro_client_url",
|
||||
label: "Nitro client URL",
|
||||
description: "Absolute or same-origin URL for the Nitro iframe.",
|
||||
type: "url",
|
||||
placeholder: "https://…/client/",
|
||||
},
|
||||
{
|
||||
key: "nitro_files_root",
|
||||
label: "Nitro-Files root (server path)",
|
||||
description:
|
||||
"Filesystem root used when importing furni / writing live assets.",
|
||||
type: "text",
|
||||
placeholder: "E:\\path\\to\\Nitro-Files",
|
||||
},
|
||||
{
|
||||
key: "gamedata_root",
|
||||
label: "Production Gamedata root (server path)",
|
||||
description:
|
||||
"Filesystem root served at /gamedata. Auto-detected as /var/www/Gamedata when present.",
|
||||
type: "text",
|
||||
placeholder: "/var/www/Gamedata",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "assets",
|
||||
title: "Game assets",
|
||||
icon: "music",
|
||||
description: "Public URLs and overrides for SWF / Nitro asset packages.",
|
||||
fields: [
|
||||
{
|
||||
key: "furnidata_translate_enabled",
|
||||
label: "Translate furniture names",
|
||||
description:
|
||||
"Rebuild FurnitureData_<lang>.json with translated names after each import. Disable to skip the LibreTranslate / deep-clone work and save CPU & RAM when you don't need localized furniture names. You can still rebuild on demand via the import 'build languages' action.",
|
||||
type: "boolean",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
key: "habbo_gamedata_hotel",
|
||||
label: "Habbo hotel for furni names",
|
||||
description:
|
||||
"Official Habbo locale used for catalog name suggestions, furni import enrichment, and badge text lookup (furnidata / external texts).",
|
||||
type: "select",
|
||||
defaultValue: "it",
|
||||
options: HABBO_GAMEDATA_HOTELS.map((h) => ({
|
||||
value: h.value,
|
||||
label: h.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "soundtrack_base_url",
|
||||
label: "Song disks MP3 base URL",
|
||||
type: "url",
|
||||
defaultValue: "/swf/dcr/hof_furni/mp3/",
|
||||
},
|
||||
{
|
||||
key: "furni_data_mirror_path",
|
||||
label: "FurnitureData mirror path",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "figure_swf_base_url",
|
||||
label: "Figure SWF base URL (override)",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "effect_swf_base_url",
|
||||
label: "Effect SWF base URL (override)",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "pet_swf_base_url",
|
||||
label: "Pet SWF base URL (override)",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "radio",
|
||||
title: "Radio",
|
||||
icon: "music",
|
||||
description: "Public radio player and DJ monitoring feeds.",
|
||||
fields: [
|
||||
{
|
||||
key: "radio_enabled",
|
||||
label: "Enable radio player",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "radio_name",
|
||||
label: "Radio display name",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "radio_stream_url",
|
||||
label: "Stream URL",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "radio_now_playing_api_url",
|
||||
label: "Now-playing API URL",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "radio_listeners_api_url",
|
||||
label: "Listeners API URL",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
title: "Security & registration",
|
||||
icon: "shield",
|
||||
description: "Account limits, captcha and staff 2FA.",
|
||||
fields: [
|
||||
{
|
||||
key: "force_staff_2fa",
|
||||
label: "Require 2FA for staff",
|
||||
description: "Staff without 2FA are redirected to set it up.",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "require_email_verification",
|
||||
label: "Require email verification",
|
||||
description:
|
||||
"Block login until the account email is verified (accounts without email are unaffected).",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "max_accounts_per_ip",
|
||||
label: "Max accounts per IP",
|
||||
description: "0 = unlimited.",
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "captcha_provider",
|
||||
label: "Captcha provider",
|
||||
description: "none | turnstile | recaptcha | hcaptcha",
|
||||
type: "text",
|
||||
defaultValue: "none",
|
||||
},
|
||||
{
|
||||
key: "turnstile_site_key",
|
||||
label: "Turnstile site key",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "hcaptcha_site_key",
|
||||
label: "hCaptcha site key",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "recaptcha_site_key",
|
||||
label: "reCAPTCHA site key",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "abuse_guard_enabled",
|
||||
label: "Enable abuse guard",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "abuse_guard_threshold",
|
||||
label: "Abuse guard threshold",
|
||||
type: "number",
|
||||
defaultValue: "200",
|
||||
},
|
||||
{
|
||||
key: "abuse_guard_window_seconds",
|
||||
label: "Abuse guard window (seconds)",
|
||||
type: "number",
|
||||
defaultValue: "10",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "misc",
|
||||
title: "Other",
|
||||
icon: "link",
|
||||
description: "Extra hotel features.",
|
||||
fields: [
|
||||
{
|
||||
key: "drawbadge.price",
|
||||
label: "Draw-badge price",
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MANAGED_SETTING_KEYS: string[] = SETTINGS_GROUPS.flatMap((g) =>
|
||||
g.fields.map((f) => f.key),
|
||||
);
|
||||
|
||||
export const BOOLEAN_KEYS = new Set(
|
||||
SETTINGS_GROUPS.flatMap((g) =>
|
||||
g.fields.filter((f) => f.type === "boolean").map((f) => f.key),
|
||||
),
|
||||
);
|
||||
|
||||
export const FIELD_DEFAULTS: Record<string, string> = Object.fromEntries(
|
||||
SETTINGS_GROUPS.flatMap((g) =>
|
||||
g.fields
|
||||
.filter((f) => f.defaultValue != null)
|
||||
.map((f) => [f.key, f.defaultValue as string]),
|
||||
),
|
||||
);
|
||||
@@ -139,9 +139,6 @@ describe("requireStaffRateLimited", () => {
|
||||
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
||||
vi.mocked(rateLimit).mockResolvedValue({ ok: false });
|
||||
await requireStaffRateLimited();
|
||||
expect(redirectSafe).toHaveBeenCalledWith(
|
||||
"/admin?error=ratelimit",
|
||||
"/admin",
|
||||
);
|
||||
expect(redirectSafe).toHaveBeenCalledWith("/ase?error=ratelimit", "/ase");
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export async function requireHousekeepingCapability(
|
||||
context ?? (await getHousekeepingCapabilityContext());
|
||||
const authorization = authorizeHousekeeping(capabilityContext, requirement);
|
||||
|
||||
if (!authorization.ok) redirectSafe("/admin", "/admin");
|
||||
if (!authorization.ok) redirectSafe("/ase", "/ase");
|
||||
|
||||
return capabilityContext;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export async function requirePermission(
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||
redirectSafe("/", "/");
|
||||
if (!canAccess(permissions, permission, session.user.rank))
|
||||
redirectSafe("/admin", "/admin");
|
||||
redirectSafe("/ase", "/ase");
|
||||
return {
|
||||
id: session.user.id,
|
||||
rank: session.user.rank,
|
||||
@@ -62,7 +62,7 @@ export async function requirePermissionRateLimited(
|
||||
const staff = await requirePermission(permission);
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
redirectSafe("/ase?error=ratelimit", "/ase");
|
||||
return staff;
|
||||
}
|
||||
|
||||
@@ -70,13 +70,13 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
redirectSafe("/ase?error=ratelimit", "/ase");
|
||||
return staff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mod-lite gate: needs any mod.* / moderation ACL, not admin.dashboard.
|
||||
* Use for `/mod` layout so mid-ranks can access without full housekeeping.
|
||||
* Retained for capability-compatible action adapters used by mid-rank staff.
|
||||
*/
|
||||
export async function requireMod(): Promise<StaffUser> {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
@@ -106,6 +106,6 @@ export async function requireModPermission(
|
||||
const { permissions } = await getAdminContext();
|
||||
const needed = Array.isArray(permission) ? permission : [permission];
|
||||
const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank));
|
||||
if (!ok) redirectSafe("/mod", "/mod");
|
||||
if (!ok) redirectSafe("/ase", "/ase");
|
||||
return staff;
|
||||
}
|
||||
@@ -60,7 +60,6 @@ async function fetchCmsCandidates(
|
||||
limit: number,
|
||||
search: string,
|
||||
openOnly: boolean,
|
||||
base: "admin" | "mod",
|
||||
): Promise<TicketInboxRow[]> {
|
||||
const Creator = alias(User, "inbox_ticket_creator");
|
||||
const conditions: SQL[] = [];
|
||||
@@ -102,7 +101,7 @@ async function fetchCmsCandidates(
|
||||
.limit(limit)
|
||||
.catch(() => []);
|
||||
|
||||
const prefix = base === "mod" ? "/mod/tickets" : "/admin/tickets";
|
||||
const prefix = "/ase/people/support/tickets";
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: `cms-${row.id}`,
|
||||
@@ -123,7 +122,6 @@ async function fetchHelpCandidates(
|
||||
limit: number,
|
||||
search: string,
|
||||
openOnly: boolean,
|
||||
base: "admin" | "mod",
|
||||
): Promise<TicketInboxRow[]> {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
@@ -192,7 +190,7 @@ async function fetchHelpCandidates(
|
||||
: [];
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const prefix = base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets";
|
||||
const prefix = "/ase/people/support/help-tickets";
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: `help-${row.id}`,
|
||||
@@ -302,7 +300,6 @@ async function fetchStrictUnifiedTicketInbox(
|
||||
numericCandidate > 0
|
||||
? numericCandidate
|
||||
: null;
|
||||
const base = options.base ?? "admin";
|
||||
if (
|
||||
!Number.isFinite(options.perPage) ||
|
||||
!Number.isFinite(options.page) ||
|
||||
@@ -441,12 +438,8 @@ async function fetchStrictUnifiedTicketInbox(
|
||||
}
|
||||
const prefix =
|
||||
row.kind === "cms"
|
||||
? base === "mod"
|
||||
? "/mod/tickets"
|
||||
: "/admin/tickets"
|
||||
: base === "mod"
|
||||
? "/mod/help-tickets"
|
||||
: "/admin/help-tickets";
|
||||
? "/ase/people/support/tickets"
|
||||
: "/ase/people/support/help-tickets";
|
||||
const dateValue =
|
||||
row.dateValue === null
|
||||
? null
|
||||
@@ -497,7 +490,6 @@ export async function fetchUnifiedTicketInbox(
|
||||
const type = options.type ?? "all";
|
||||
const openOnly = options.openOnly !== false;
|
||||
const search = options.search?.trim() ?? "";
|
||||
const base = options.base ?? "admin";
|
||||
|
||||
const includeCms = type === "all" || type === "cms";
|
||||
const includeHelp = type === "all" || type === "help";
|
||||
@@ -513,10 +505,10 @@ export async function fetchUnifiedTicketInbox(
|
||||
|
||||
const [cmsRows, helpRows] = await Promise.all([
|
||||
includeCms
|
||||
? fetchCmsCandidates(window, search, openOnly, base)
|
||||
? fetchCmsCandidates(window, search, openOnly)
|
||||
: Promise.resolve([]),
|
||||
includeHelp
|
||||
? fetchHelpCandidates(window, search, openOnly, base)
|
||||
? fetchHelpCandidates(window, search, openOnly)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ const SAFE_REDIRECT_PATHS = new Set([
|
||||
function isSafePath(path: string): boolean {
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/admin/") || path.startsWith("/api/")) return true;
|
||||
if (path.startsWith("/ase/") || path.startsWith("/api/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,6 @@ describe("admin import backend contract", () => {
|
||||
expect(source, path).toContain("PERMS.ASSETS_IMPORT");
|
||||
});
|
||||
|
||||
it("does not leave import actions as successful no-op stubs", () => {
|
||||
const source = readFileSync("src/actions/import-furni.ts", "utf8");
|
||||
expect(source).not.toContain("deleted: 0, remaining: 0");
|
||||
});
|
||||
|
||||
it("keeps badge import ExternalTexts + WebsiteBadges in sync", () => {
|
||||
const service = readFileSync("src/lib/services/import-badge.ts", "utf8");
|
||||
expect(service).toContain("ExternalTexts.json");
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldRedirectAdminRequest } from "./proxy-access";
|
||||
import { shouldRedirectHousekeepingRequest } from "./proxy-access";
|
||||
|
||||
describe("shouldRedirectAdminRequest", () => {
|
||||
it("redirects anonymous admin requests before rendering", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin", null)).toBe(true);
|
||||
describe("shouldRedirectHousekeepingRequest", () => {
|
||||
it("redirects anonymous Housekeeping requests before rendering", () => {
|
||||
expect(shouldRedirectHousekeepingRequest("/ase", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("defers every authenticated rank to database authorization", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 1 })).toBe(
|
||||
expect(
|
||||
shouldRedirectHousekeepingRequest("/ase/system/access/permissions", {
|
||||
rank: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRedirectHousekeepingRequest("/ase/system/access/permissions", {
|
||||
rank: 2000,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(shouldRedirectHousekeepingRequest("/api/admin/csrf", null)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
shouldRedirectAdminRequest("/admin/permissions", { rank: 2000 }),
|
||||
).toBe(false);
|
||||
expect(shouldRedirectAdminRequest("/news", null)).toBe(false);
|
||||
expect(shouldRedirectHousekeepingRequest("/news", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ export interface ProxyToken {
|
||||
rank?: unknown;
|
||||
}
|
||||
|
||||
export function shouldRedirectAdminRequest(
|
||||
export function shouldRedirectHousekeepingRequest(
|
||||
pathname: string,
|
||||
token: ProxyToken | null,
|
||||
): boolean {
|
||||
if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return false;
|
||||
if (pathname !== "/ase" && !pathname.startsWith("/ase/")) return false;
|
||||
return token === null;
|
||||
}
|
||||
@@ -156,7 +156,7 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
||||
: "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/ase/system/operations/alerts">view alerts</a></p>`;
|
||||
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { toast } from "sonner";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
export type SseEvent = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Read an SSE response body and invoke `onEvent` for each `data:` JSON payload.
|
||||
*/
|
||||
export async function readSseStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onEvent: (event: SseEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(part.slice(6)) as SseEvent);
|
||||
} catch {
|
||||
/* skip malformed events */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON to an admin SSE import endpoint and drive the standard
|
||||
* item_progress / batch_complete callbacks used by clothing & clone clients.
|
||||
*/
|
||||
export async function runSseImport(
|
||||
url: string,
|
||||
body: unknown,
|
||||
onDone: (classname: string) => void,
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
signal?: AbortSignal,
|
||||
onFailed?: (classname: string, error?: string) => void,
|
||||
): Promise<void> {
|
||||
const res = await adminFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(
|
||||
typeof data.error === "string"
|
||||
? data.error
|
||||
: `Import failed (${res.status})`,
|
||||
);
|
||||
onComplete(0, 0);
|
||||
return;
|
||||
}
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
onComplete(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
await readSseStream(
|
||||
res.body,
|
||||
(evt) => {
|
||||
if (evt.type === "item_progress") {
|
||||
const classname = String(evt.classname ?? "");
|
||||
if (evt.status === "done") {
|
||||
onDone(classname);
|
||||
} else if (evt.status === "failed") {
|
||||
onFailed?.(classname, String(evt.error ?? ""));
|
||||
}
|
||||
}
|
||||
if (evt.type === "error") {
|
||||
toast.error(
|
||||
typeof evt.message === "string"
|
||||
? evt.message
|
||||
: "Import finished with errors",
|
||||
);
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = Number(evt.succeeded ?? 0);
|
||||
failed = Number(evt.failed ?? 0);
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
onComplete(succeeded, failed);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("staff smoke contract", () => {
|
||||
it("gates photos delete with PAGES_EDIT", () => {
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(wrapper).toContain("PERMS.PAGES_EDIT");
|
||||
expect(wrapper).toContain("deletePhoto");
|
||||
expect(wrapper).toContain("contentMutationService.execute");
|
||||
expect(wrapper).toContain('"photo.delete"');
|
||||
expect(runtime).toContain("logStaffActivity");
|
||||
expect(existsSync("src/app/admin/photos/page.tsx")).toBe(true);
|
||||
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
||||
"AdminPageShell",
|
||||
);
|
||||
});
|
||||
|
||||
it("gates bans create/lift", () => {
|
||||
expect(existsSync("src/app/admin/bans/page.tsx")).toBe(true);
|
||||
const bans = readFileSync("src/actions/admin-bans.ts", "utf8");
|
||||
expect(bans).toContain("liftBan");
|
||||
expect(bans).toContain("createBan");
|
||||
expect(readFileSync("src/app/admin/bans/page.tsx", "utf8")).toContain(
|
||||
"AdminPageShell",
|
||||
);
|
||||
});
|
||||
|
||||
it("wires ban appeal lift on help tickets", () => {
|
||||
const actions = readFileSync("src/actions/admin-help-tickets.ts", "utf8");
|
||||
expect(actions).toContain("liftBanFromHelpTicket");
|
||||
expect(actions).toContain("PERMS.USERS_BAN");
|
||||
const detail = readFileSync(
|
||||
"src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx",
|
||||
"utf8",
|
||||
);
|
||||
expect(detail).toContain("liftBanFromHelpTicket");
|
||||
expect(detail).toContain("canLiftBan");
|
||||
});
|
||||
|
||||
it("exposes sanction timeline on user show", () => {
|
||||
expect(
|
||||
existsSync("src/app/admin/users/_components/user-sanction-timeline.tsx"),
|
||||
).toBe(true);
|
||||
expect(existsSync("src/app/admin/users/_lib/load-user-sanctions.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/app/admin/users/show/[id]/page.tsx", "utf8"),
|
||||
).toContain("UserSanctionTimeline");
|
||||
});
|
||||
|
||||
it("applies CMS SQL from drizzle/migrations (not Prisma)", () => {
|
||||
expect(existsSync("drizzle/migrations")).toBe(true);
|
||||
expect(existsSync("prisma")).toBe(false);
|
||||
expect(readFileSync("scripts/apply-migrations.ts", "utf8")).toContain(
|
||||
"drizzle/migrations",
|
||||
);
|
||||
const pkg = readFileSync("package.json", "utf8");
|
||||
expect(pkg).not.toContain('"prisma"');
|
||||
expect(pkg).not.toContain('"@prisma/client"');
|
||||
expect(pkg).not.toContain("prisma:generate");
|
||||
expect(pkg).toContain('"db:generate": "drizzle-kit generate"');
|
||||
expect(pkg).toContain('"db:studio": "drizzle-kit studio"');
|
||||
expect(pkg).toContain('"db:migrate": "tsx scripts/apply-migrations.ts"');
|
||||
expect(readFileSync("drizzle.config.ts", "utf8")).toContain(
|
||||
"./drizzle/drafts",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships shared ops + ticket queue helpers", () => {
|
||||
expect(existsSync("src/lib/admin/ops-health.ts")).toBe(true);
|
||||
const ops = readFileSync("src/lib/admin/ops-health.ts", "utf8");
|
||||
expect(ops).toContain("redisOk");
|
||||
expect(ops).toContain("redis.ping");
|
||||
expect(existsSync("src/lib/admin/ticket-queue-counts.ts")).toBe(true);
|
||||
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("opts admin/mod out of static caching via instant=false", () => {
|
||||
// The Cache Components migration replaced `force-dynamic` with
|
||||
// `export const instant = false` — the legacy export must not survive.
|
||||
let legacyHits = 0;
|
||||
function walk(dir: string) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p);
|
||||
else if (/\.(tsx?|jsx?)$/.test(name)) {
|
||||
const src = readFileSync(p, "utf8");
|
||||
if (
|
||||
/export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/.test(src)
|
||||
) {
|
||||
legacyHits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("src/app/admin");
|
||||
walk("src/app/mod");
|
||||
expect(legacyHits).toBe(0);
|
||||
|
||||
// The root layout carries the single Cache Components opt-out; staff
|
||||
// pages inherit it (was force-dynamic, then per-layout instant=false).
|
||||
expect(readFileSync("src/app/layout.tsx", "utf8")).toContain(
|
||||
"export const instant = false",
|
||||
);
|
||||
// Staff layouts must not redeclare the opt-out — that is the root
|
||||
// layout's job now, and redeclaring it is the legacy per-layout pattern.
|
||||
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).not.toContain(
|
||||
"export const instant",
|
||||
);
|
||||
expect(readFileSync("src/app/mod/layout.tsx", "utf8")).not.toContain(
|
||||
"export const instant",
|
||||
);
|
||||
});
|
||||
|
||||
it("caches analytics via redisCache", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/analytics/page.tsx",
|
||||
"src/app/admin/analytics/activity/page.tsx",
|
||||
"src/app/admin/analytics/economy/page.tsx",
|
||||
]) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain("redisCache");
|
||||
}
|
||||
});
|
||||
|
||||
it("exports bulk adjust currency and keeps trade-lock DB/RCON behavior", () => {
|
||||
const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||
const service = readFileSync(
|
||||
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(wrapper).toContain("bulkAdjustCurrency");
|
||||
expect(wrapper).toContain("setTradeLock");
|
||||
expect(service).toContain("tradeLockedUntil");
|
||||
expect(service).toContain("UsersSettings");
|
||||
expect(service).toContain("rcon.setTradeLock");
|
||||
});
|
||||
|
||||
it("keeps Drizzle photo deletion and local purge in the Content runtime", () => {
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(wrapper).toContain("contentMutationService.execute");
|
||||
expect(wrapper).toContain('"photo.delete"');
|
||||
expect(runtime).toContain("CameraWeb");
|
||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(runtime).toContain("@/lib/admin/photo-files");
|
||||
expect(runtime).toContain("@/lib/db");
|
||||
});
|
||||
|
||||
it("guards dual ticket queues on admin and mod", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/tickets/desk/page.tsx",
|
||||
"src/app/admin/help-tickets/page.tsx",
|
||||
"src/app/mod/tickets/desk/page.tsx",
|
||||
"src/app/mod/help-tickets/page.tsx",
|
||||
"src/app/admin/tickets/[id]/page.tsx",
|
||||
"src/app/admin/help-tickets/[id]/page.tsx",
|
||||
"src/app/mod/tickets/[id]/page.tsx",
|
||||
"src/app/mod/help-tickets/[id]/page.tsx",
|
||||
]) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain("TicketQueueBanner");
|
||||
expect(readFileSync(path, "utf8"), path).toContain(
|
||||
"fetchTicketQueueOpenCounts",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("ships unified ticket inbox over both queues", () => {
|
||||
expect(existsSync("src/lib/admin/ticket-inbox.ts")).toBe(true);
|
||||
expect(readFileSync("src/lib/admin/ticket-inbox.ts", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(existsSync("src/components/admin/unified-tickets-table.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(readFileSync("src/app/admin/tickets/page.tsx", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(readFileSync("src/app/mod/tickets/page.tsx", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||
"ticketInbox",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps StatusCard server-safe without client boundary", () => {
|
||||
expect(
|
||||
readFileSync("src/components/admin/dashboard.tsx", "utf8"),
|
||||
).not.toMatch(/^["']use client["']/m);
|
||||
expect(existsSync("src/components/admin/online-users-widget.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/components/admin/online-users-widget.tsx", "utf8"),
|
||||
).toContain("use client");
|
||||
expect(existsSync("src/app/admin/ads/edit-ad-delete-button.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ships ops health alerts, optional DB backup, and health rate limit", () => {
|
||||
const worker = readFileSync("scripts/jobs-worker.ts", "utf8");
|
||||
expect(worker).toContain("checkOpsHealth");
|
||||
expect(worker).toContain("healthDegraded");
|
||||
expect(worker).toContain("backupDatabase");
|
||||
expect(readFileSync("src/lib/services/alert.ts", "utf8")).toContain(
|
||||
"healthDegraded",
|
||||
);
|
||||
expect(readFileSync("src/app/api/health/route.ts", "utf8")).toContain(
|
||||
"rateLimit",
|
||||
);
|
||||
expect(readFileSync("src/actions/admin-alerts.ts", "utf8")).toContain(
|
||||
"markAllAlertsRead",
|
||||
);
|
||||
expect(readFileSync("src/env.ts", "utf8")).toContain("DB_BACKUP_DIR");
|
||||
});
|
||||
|
||||
it("documents local-only photo file purge", () => {
|
||||
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
||||
"purgeHint",
|
||||
);
|
||||
expect(readFileSync("src/messages/en.json", "utf8")).toContain(
|
||||
"External CDN URLs are not purged",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships items base admin CRUD", () => {
|
||||
expect(existsSync("src/app/admin/items/page.tsx")).toBe(true);
|
||||
expect(existsSync("src/app/admin/items/[id]/page.tsx")).toBe(true);
|
||||
expect(readFileSync("src/actions/items-base.ts", "utf8")).toContain(
|
||||
"updateItemsBase",
|
||||
);
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||
"/admin/items",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships studio hub with synced badge import path", () => {
|
||||
const nav = readFileSync("src/lib/admin-nav.ts", "utf8");
|
||||
expect(nav).toContain('href: "/admin/studio"');
|
||||
expect(nav).toContain('labelKey: "studio"');
|
||||
expect(nav).toContain('"/admin/studio"');
|
||||
expect(existsSync("src/lib/services/import-badge.ts")).toBe(true);
|
||||
const badgeService = readFileSync(
|
||||
"src/lib/services/import-badge.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(badgeService).toContain("ExternalTexts.json");
|
||||
expect(badgeService).toContain("WebsiteBadges");
|
||||
expect(badgeService).toContain("importBadgeSynced");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user