feat(housekeeping): cut over administration to ase

This commit is contained in:
Simo committed 2026-08-30 20:35:22 +02:00
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);
});
});
-73
View File
@@ -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,
};
}
-88
View File
@@ -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"] });
});
});
-120
View File
@@ -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 ?? {},
});
}
-93
View File
@@ -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());
});
});
-706
View File
@@ -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 -105
View File
@@ -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");
+16 -28
View File
@@ -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([]);
});
});
+343
View File
@@ -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]),
),
);
+1 -4
View File
@@ -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");
});
});
+6 -6
View File
@@ -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;
}
+6 -14
View File
@@ -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([]),
]);
+1 -1
View File
@@ -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;
}
-5
View File
@@ -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");
+16 -9
View File
@@ -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);
});
});
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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);
-107
View File
@@ -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);
}
-276
View File
@@ -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");
});
});