fix(housekeeping): link only reachable preview routes

This commit is contained in:
Simo committed 2026-08-31 18:45:45 +02:00
1 parent 543607a80e
commit d8fb8edff6
6 files changed
+216 -162

No files matched your search

+7 -1
View File
@@ -4,9 +4,11 @@ import type { ReactNode } from "react";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { createHousekeepingRouteRuntime } from "@/features/housekeeping/foundation/routing/runtime";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers";
const MESSAGE_PREFIX = "pages.housekeeping.";
@@ -27,6 +29,10 @@ export default async function AdminNextDomainLayout({
}) {
const { domain } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const runtime = createHousekeepingRouteRuntime(
registry,
HOUSEKEEPING_ROUTE_HANDLERS,
);
const activeDomain = registry.domains.find((entry) => entry.id === domain);
if (!activeDomain) notFound();
@@ -35,7 +41,7 @@ export default async function AdminNextDomainLayout({
if (!satisfiesCapability(context, activeDomain.capability)) notFound();
const translate = await getTranslations("pages.housekeeping");
const navigation = buildHousekeepingNavigation(registry, context, (key) =>
const navigation = buildHousekeepingNavigation(runtime, context, (key) =>
translate(namespaceKey(key) as never),
);
@@ -1,11 +1,17 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { anyCapability, type HousekeepingCapabilityContext } from "./contracts";
import {
anyCapability,
type HousekeepingCapabilityContext,
type HousekeepingDomainManifest,
} from "./contracts";
import { buildHousekeepingNavigation } from "./navigation";
import { createHousekeepingRegistry } from "./registry";
import type { HousekeepingRouteHandler } from "./routing/route-handler";
import { createHousekeepingRouteRuntime } from "./routing/runtime";
const context = (
const capabilityContext = (
granted: readonly string[],
): HousekeepingCapabilityContext => ({
actor: { id: 42, username: "moderator", rank: 3 },
@@ -15,159 +21,168 @@ const context = (
hasAll: (...slugs) => slugs.every((slug) => granted.includes(slug)),
});
describe("housekeeping navigation", () => {
it("shows People to a moderator with a mod view capability while hiding Economy", () => {
const registry = createHousekeepingRegistry([
{
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
previewHref: "/ase-next/people",
capability: anyCapability(PERMS.MOD_CFH_VIEW),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
{
id: "economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem",
previewHref: "/ase-next/economy",
capability: anyCapability(PERMS.CATALOG_VIEW),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
]);
const peopleManifest = {
id: "people",
labelKey: "people.title",
descriptionKey: "people.description",
iconId: "users",
previewHref: "/ase-next/people",
capability: anyCapability(
PERMS.USERS_VIEW,
PERMS.TICKETS_VIEW,
PERMS.BANS_VIEW,
),
landingRouteId: "people.users",
routes: [
{
id: "people.users",
labelKey: "people.users",
href: "/ase-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "people.user-detail",
labelKey: "people.userDetail",
href: "/ase-next/people/users/:id",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "people.tickets",
labelKey: "people.tickets",
href: "/ase-next/people/support/tickets",
capability: anyCapability(PERMS.TICKETS_VIEW),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
const handler = (routeId: string): HousekeepingRouteHandler => ({
routeId,
render: async () => `Rendered ${routeId}`,
});
const peopleRuntime = createHousekeepingRouteRuntime(
createHousekeepingRegistry([peopleManifest]),
peopleManifest.routes.map((route) => handler(route.id)),
);
const identityTranslate = (key: string) => key;
describe("housekeeping navigation", () => {
it("links a domain to its accessible handled landing route", () => {
const navigation = buildHousekeepingNavigation(
registry,
context([PERMS.MOD_CFH_VIEW]),
(key) => key,
peopleRuntime,
capabilityContext([PERMS.USERS_VIEW]),
identityTranslate,
);
expect(navigation).toEqual([
{
id: "people",
href: "/ase-next/people",
href: "/ase-next/people/users",
iconId: "users",
label: "pages.housekeeping.domains.people.title",
description: "pages.housekeeping.domains.people.description",
items: [],
label: "people.title",
description: "people.description",
items: [
{
id: "people.users",
href: "/ase-next/people/users",
label: "people.users",
},
],
},
]);
});
it("filters unauthorized domains and routes before translation", () => {
const registry = createHousekeepingRegistry([
{
id: "people",
labelKey: "people.title",
descriptionKey: "people.description",
iconId: "users",
previewHref: "/ase-next/people",
capability: anyCapability(PERMS.USERS_VIEW),
landingRouteId: "users",
routes: [
{
id: "users",
labelKey: "people.users",
href: "/ase-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "bans",
labelKey: "people.bans",
href: "/ase-next/people/bans",
capability: anyCapability(PERMS.BANS_VIEW),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
},
{
id: "system",
labelKey: "system.title",
descriptionKey: "system.description",
iconId: "settings",
previewHref: "/ase-next/system",
capability: anyCapability(PERMS.SETTINGS_VIEW),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
it("falls back to the first accessible handled route", () => {
const navigation = buildHousekeepingNavigation(
peopleRuntime,
capabilityContext([PERMS.TICKETS_VIEW]),
identityTranslate,
);
expect(navigation[0]?.href).toBe("/ase-next/people/support/tickets");
expect(navigation[0]?.items.map((item) => item.id)).toEqual([
"people.tickets",
]);
});
it("omits a domain with no accessible handled concrete route", () => {
expect(
buildHousekeepingNavigation(
peopleRuntime,
capabilityContext([PERMS.BANS_VIEW]),
identityTranslate,
),
).toEqual([]);
});
it("does not expose dynamic route patterns as navigation links", () => {
const navigation = buildHousekeepingNavigation(
peopleRuntime,
capabilityContext([PERMS.USERS_VIEW]),
identityTranslate,
);
expect(navigation[0]?.items.map((item) => item.id)).not.toContain(
"people.user-detail",
);
expect(navigation[0]?.items.map((item) => item.href)).not.toContain(
"/ase-next/people/users/:id",
);
});
it("filters inaccessible routes before translation", () => {
const translate = vi.fn((key: string) => `translated:${key}`);
const navigation = buildHousekeepingNavigation(
registry,
context([PERMS.USERS_VIEW]),
buildHousekeepingNavigation(
peopleRuntime,
capabilityContext([PERMS.TICKETS_VIEW]),
translate,
);
expect(navigation).toEqual([
{
id: "people",
href: "/ase-next/people",
iconId: "users",
label: "translated:people.title",
description: "translated:people.description",
items: [
{
id: "users",
href: "/ase-next/people/users",
label: "translated:people.users",
},
],
},
]);
expect(translate).not.toHaveBeenCalledWith("people.bans");
expect(translate).not.toHaveBeenCalledWith("system.title");
expect(translate).not.toHaveBeenCalledWith("system.description");
expect(translate).toHaveBeenCalledWith("people.title");
expect(translate).toHaveBeenCalledWith("people.description");
expect(translate).toHaveBeenCalledWith("people.tickets");
expect(translate).not.toHaveBeenCalledWith("people.users");
expect(translate).not.toHaveBeenCalledWith("people.userDetail");
});
it("shows real domains to operators authorized by owned migration capabilities", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const translate = (key: string) => key;
it("keeps current empty manifests out of navigation until a vertical ships", () => {
const emptyRuntime = createHousekeepingRouteRuntime(
createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
[],
);
expect(
buildHousekeepingNavigation(
registry,
context([PERMS.MOD_CFH_VIEW]),
translate,
).map((domain) => domain.id),
).toContain("people");
expect(
buildHousekeepingNavigation(
registry,
context([PERMS.MOD_CFH_VIEW]),
translate,
).map((domain) => domain.id),
).not.toContain("economy");
emptyRuntime,
capabilityContext([PERMS.MOD_CFH_VIEW]),
identityTranslate,
),
).toEqual([]);
});
for (const [slug, expectedDomain] of [
[PERMS.MOD_ACTIONS, "people"],
[PERMS.USERS_EDIT, "people"],
[PERMS.SETTINGS_VIEW, "people"],
[PERMS.NEWS_EDIT, "content"],
[PERMS.SHOP_EDIT, "economy"],
[PERMS.ROOMS_EDIT, "hotel"],
] as const) {
const visibleDomainIds = buildHousekeepingNavigation(
registry,
context([slug]),
translate,
).map((domain) => domain.id);
it("projects only hrefs that the runtime can resolve", () => {
const capabilityProfiles = [
capabilityContext([PERMS.USERS_VIEW]),
capabilityContext([PERMS.TICKETS_VIEW]),
capabilityContext([PERMS.BANS_VIEW]),
capabilityContext([PERMS.USERS_VIEW, PERMS.TICKETS_VIEW]),
];
expect(visibleDomainIds, slug).toContain(expectedDomain);
for (const profile of capabilityProfiles) {
for (const domain of buildHousekeepingNavigation(
peopleRuntime,
profile,
identityTranslate,
)) {
expect(peopleRuntime.match(domain.href), domain.href).not.toBeNull();
for (const item of domain.items) {
expect(peopleRuntime.match(item.href), item.href).not.toBeNull();
}
}
}
});
});
@@ -3,37 +3,63 @@ import { satisfiesCapability } from "./capability-context";
import type {
HousekeepingCapabilityContext,
HousekeepingDomainManifest,
HousekeepingPreviewHref,
} from "./contracts";
import type { HousekeepingRegistry } from "./registry";
import type { HousekeepingRouteRuntime } from "./routing/runtime";
export interface HousekeepingNavigationDomain {
id: HousekeepingDomainId;
href: string;
href: HousekeepingPreviewHref;
iconId: HousekeepingDomainManifest["iconId"];
label: string;
description: string;
items: readonly { id: string; href: string; label: string }[];
items: readonly {
id: string;
href: HousekeepingPreviewHref;
label: string;
}[];
}
export function buildHousekeepingNavigation(
registry: HousekeepingRegistry,
runtime: HousekeepingRouteRuntime,
context: HousekeepingCapabilityContext,
translate: (key: string) => string,
): readonly HousekeepingNavigationDomain[] {
return registry.domains
.filter((domain) => satisfiesCapability(context, domain.capability))
.map((domain) => ({
id: domain.id,
href: domain.previewHref,
iconId: domain.iconId,
label: translate(domain.labelKey),
description: translate(domain.descriptionKey),
items: domain.routes
.filter((route) => satisfiesCapability(context, route.capability))
.map((route) => ({
id: route.id,
href: route.href,
label: translate(route.labelKey),
})),
}));
return runtime.registry.domains.flatMap((domain) => {
if (!satisfiesCapability(context, domain.capability)) return [];
const items = domain.routes
.filter(
(route) =>
runtime.handlers.has(route.id) &&
isConcreteNavigationHref(route.href) &&
satisfiesCapability(context, route.capability),
)
.map((route) => ({
id: route.id,
href: route.href,
label: translate(route.labelKey),
}));
if (items.length === 0) return [];
const landing =
items.find((item) => item.id === domain.landingRouteId) ?? items[0];
if (!landing) return [];
return [
{
id: domain.id,
href: landing.href,
iconId: domain.iconId,
label: translate(domain.labelKey),
description: translate(domain.descriptionKey),
items,
},
];
});
}
function isConcreteNavigationHref(href: HousekeepingPreviewHref): boolean {
return !href.split("/").some((segment) => segment.startsWith(":"));
}
@@ -483,7 +483,7 @@ describe("/ase-next/[domain] layout", () => {
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("renders localized People shell from one refreshed capability context", async () => {
it("renders the shell without exposing a domain that has no concrete routes", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
@@ -502,9 +502,12 @@ describe("/ase-next/[domain] layout", () => {
expect(html).toContain("HK::command-disabled");
expect(html).toContain("HK::preview-badge");
expect(html).toContain("HK::back-to-site");
expect(html).toContain("HK::people-title");
expect(html).not.toContain("HK::people-title");
expect(html).toContain("People body");
expect(html).not.toContain("Localized Economy");
expect(routeMocks.translate).not.toHaveBeenCalledWith(
"domains.people.title",
);
expect(routeMocks.translate).not.toHaveBeenCalledWith(
"domains.economy.title",
);
@@ -17,7 +17,7 @@ const labels = {
const domains: readonly HousekeepingNavigationDomain[] = [
{
id: "operations",
href: "/ase-next/operations",
href: "/ase-next/operations/queue",
iconId: "inbox",
label: "Operations",
description: "Manage operations",
@@ -27,7 +27,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
},
{
id: "people",
href: "/ase-next/people",
href: "/ase-next/people/users",
iconId: "users",
label: "People",
description: "Manage people",
@@ -35,7 +35,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
},
{
id: "content",
href: "/ase-next/content",
href: "/ase-next/content/articles",
iconId: "file-text",
label: "Content",
description: "Manage content",
@@ -43,7 +43,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
},
{
id: "economy",
href: "/ase-next/economy",
href: "/ase-next/economy/catalog",
iconId: "gem",
label: "Economy",
description: "Manage economy",
@@ -51,7 +51,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
},
{
id: "hotel",
href: "/ase-next/hotel",
href: "/ase-next/hotel/rooms",
iconId: "hotel",
label: "Hotel",
description: "Manage hotel",
@@ -59,7 +59,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
},
{
id: "system",
href: "/ase-next/system",
href: "/ase-next/system/settings",
iconId: "settings",
label: "System",
description: "Manage system",
@@ -196,7 +196,7 @@ describe("HousekeepingShell", () => {
const activeAnchors = allAnchors.filter((anchor) =>
anchor.includes('aria-current="page"'),
);
const peopleAnchor = anchorForHref(html, "/ase-next/people");
const peopleAnchor = anchorForHref(html, "/ase-next/people/users");
for (const iconClass of [
"lucide-inbox",
"lucide-users",
@@ -222,7 +222,7 @@ describe("HousekeepingShell", () => {
);
expect(() => renderShell("system", domainsWithoutSystem)).not.toThrow();
const html = renderShell("system", domainsWithoutSystem);
expect(html).not.toContain('href="/ase-next/operations/queue"');
expect(html.match(/href="\/ase-next\/operations\/queue"/g)).toHaveLength(1);
expect(html).not.toContain(">Queue<");
});
@@ -0,0 +1,4 @@
import type { HousekeepingRouteHandler } from "./foundation/routing/route-handler";
export const HOUSEKEEPING_ROUTE_HANDLERS =
[] as const satisfies readonly HousekeepingRouteHandler[];