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