fix(housekeeping): link only reachable preview routes
This commit is contained in:
1 parent
543607a80e
commit
d8fb8edff6
6 files changed
+216
-162
No files matched your search
@@ -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[];
|
||||
Reference in new issue
Block a user