diff --git a/next.config.ts b/next.config.ts index 618d221b..ed2de94d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -103,6 +103,7 @@ const nextConfig: NextConfig = { }, experimental: { + authInterrupts: true, optimizePackageImports: ["lucide-react", "date-fns"], useTypeScriptCli: true, hideLogsAfterAbort: true, diff --git a/src/app/ase-next/[domain]/[[...segments]]/loading.tsx b/src/app/ase-next/[domain]/[[...segments]]/loading.tsx new file mode 100644 index 00000000..5d92a79a --- /dev/null +++ b/src/app/ase-next/[domain]/[[...segments]]/loading.tsx @@ -0,0 +1,14 @@ +import { getTranslations } from "next-intl/server"; +import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state"; + +export default async function HousekeepingRouteLoading() { + const translate = await getTranslations("pages.housekeeping"); + + return ( + + ); +} diff --git a/src/app/ase-next/[domain]/[[...segments]]/page.tsx b/src/app/ase-next/[domain]/[[...segments]]/page.tsx new file mode 100644 index 00000000..4cbcc96f --- /dev/null +++ b/src/app/ase-next/[domain]/[[...segments]]/page.tsx @@ -0,0 +1,84 @@ +import { forbidden, notFound, redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +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 { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; +import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers"; + +const MESSAGE_PREFIX = "pages.housekeeping."; + +type HousekeepingSearchParams = Readonly< + Record +>; + +function namespaceKey(key: string): string { + if (!key.startsWith(MESSAGE_PREFIX)) { + throw new Error(`invalid housekeeping message key: ${key}`); + } + + return key.slice(MESSAGE_PREFIX.length); +} + +export default async function HousekeepingDomainPage({ + params, + searchParams, +}: { + params: Promise<{ domain: string; segments?: readonly string[] }>; + searchParams?: Promise; +}) { + const { domain, segments = [] } = await params; + const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); + const activeDomain = registry.domains.find((entry) => entry.id === domain); + + if (!activeDomain) notFound(); + + const runtime = createHousekeepingRouteRuntime( + registry, + HOUSEKEEPING_ROUTE_HANDLERS, + ); + const context = await getHousekeepingCapabilityContext(); + + if (segments.length === 0) { + const navigation = buildHousekeepingNavigation( + runtime, + context, + (key) => key, + ); + const activeNavigation = navigation.find((entry) => entry.id === domain); + if (!activeNavigation) forbidden(); + redirect(activeNavigation.href); + } + + const suffix = segments + .map((segment) => encodeURIComponent(segment)) + .join("/"); + const canonicalPath = suffix + ? `${activeDomain.previewHref}/${suffix}` + : activeDomain.previewHref; + const match = runtime.match(canonicalPath); + + if (!match || match.domain !== activeDomain.id) notFound(); + + const route = activeDomain.routes.find((entry) => entry.id === match.routeId); + const handler = runtime.handlers.get(match.routeId); + if (!route || !handler) notFound(); + + if ( + !satisfiesCapability(context, activeDomain.capability) || + !satisfiesCapability(context, route.capability) + ) { + forbidden(); + } + + const translate = await getTranslations("pages.housekeeping"); + + return handler.render({ + context, + match, + searchParams: searchParams ? await searchParams : undefined, + translate: (key) => translate(namespaceKey(key) as never), + }); +} diff --git a/src/app/ase-next/[domain]/layout.tsx b/src/app/ase-next/[domain]/layout.tsx index c865c568..27707fad 100644 --- a/src/app/ase-next/[domain]/layout.tsx +++ b/src/app/ase-next/[domain]/layout.tsx @@ -1,4 +1,4 @@ -import { notFound } from "next/navigation"; +import { forbidden, notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import type { ReactNode } from "react"; import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; @@ -38,7 +38,7 @@ export default async function AdminNextDomainLayout({ if (!activeDomain) notFound(); const context = await getHousekeepingCapabilityContext(); - if (!satisfiesCapability(context, activeDomain.capability)) notFound(); + if (!satisfiesCapability(context, activeDomain.capability)) forbidden(); const translate = await getTranslations("pages.housekeeping"); const navigation = buildHousekeepingNavigation(runtime, context, (key) => diff --git a/src/app/ase-next/[domain]/page.tsx b/src/app/ase-next/[domain]/page.tsx deleted file mode 100644 index cd9f51ef..00000000 --- a/src/app/ase-next/[domain]/page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { notFound } from "next/navigation"; -import { getTranslations } from "next-intl/server"; -import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell"; -import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state"; -import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; -import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; - -const MESSAGE_PREFIX = "pages.housekeeping."; - -function namespaceKey(key: string): string { - if (!key.startsWith(MESSAGE_PREFIX)) { - throw new Error(`invalid housekeeping message key: ${key}`); - } - - return key.slice(MESSAGE_PREFIX.length); -} - -export default async function AdminNextDomainPage({ - params, -}: { - params: Promise<{ domain: string }>; -}) { - const { domain } = await params; - const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); - const activeDomain = registry.domains.find((entry) => entry.id === domain); - - if (!activeDomain) notFound(); - - const translate = await getTranslations("pages.housekeeping"); - - return ( - - - - ); -} diff --git a/src/app/ase-next/forbidden.tsx b/src/app/ase-next/forbidden.tsx new file mode 100644 index 00000000..a5f5c374 --- /dev/null +++ b/src/app/ase-next/forbidden.tsx @@ -0,0 +1,32 @@ +import { getTranslations } from "next-intl/server"; +import Link from "@/components/link"; + +export default async function HousekeepingForbidden() { + const translate = await getTranslations("pages.housekeeping"); + + return ( +
+
+

+ {translate("states.forbidden.title")} +

+

+ {translate("states.forbidden.description")} +

+ + {translate("preview.backToSite")} + +
+
+ ); +} diff --git a/src/app/ase-next/page.tsx b/src/app/ase-next/page.tsx index b36e4e7d..5e5a2f2a 100644 --- a/src/app/ase-next/page.tsx +++ b/src/app/ase-next/page.tsx @@ -1,17 +1,26 @@ -import { notFound, redirect } from "next/navigation"; -import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; +import { forbidden, redirect } from "next/navigation"; +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 { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; +import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers"; -export default async function AdminNextPage() { +export default async function HousekeepingPage() { const context = await getHousekeepingCapabilityContext(); const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); - const firstVisibleDomain = registry.domains.find((domain) => - satisfiesCapability(context, domain.capability), + const runtime = createHousekeepingRouteRuntime( + registry, + HOUSEKEEPING_ROUTE_HANDLERS, ); + const navigation = buildHousekeepingNavigation( + runtime, + context, + (key) => key, + ); + const firstAccessibleRoute = navigation[0]; - if (!firstVisibleDomain) notFound(); + if (!firstAccessibleRoute) forbidden(); - redirect(firstVisibleDomain.previewHref); + redirect(firstAccessibleRoute.href); } diff --git a/src/features/housekeeping/foundation/localization-contract.test.ts b/src/features/housekeeping/foundation/localization-contract.test.ts index b86768fa..82239e82 100644 --- a/src/features/housekeeping/foundation/localization-contract.test.ts +++ b/src/features/housekeeping/foundation/localization-contract.test.ts @@ -19,6 +19,8 @@ const requiredKeys = [ "pages.housekeeping.states.partial.description", "pages.housekeeping.states.error.title", "pages.housekeeping.states.error.description", + "pages.housekeeping.states.forbidden.title", + "pages.housekeeping.states.forbidden.description", ]; const expectedDomainMessages = [ diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 834a769f..2b745945 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -15,10 +15,19 @@ const routeMocks = vi.hoisted(() => { "navigation.skipToContent": "HK::skip-to-content", "navigation.primary": "HK::primary-navigation", "navigation.contextual": "HK::contextual-navigation", + "domains.operations.title": "HK::operations-title", + "domains.operations.description": "Localized Operations description", "domains.people.title": "HK::people-title", + "routes.operations.queue": "HK::operations-queue", + "routes.people.users": "HK::people-users", + "routes.people.moderation": "HK::people-moderation", + "routes.people.tickets": "HK::people-tickets", + "routes.economy.catalog": "HK::economy-catalog", "domains.people.description": "Localized People description", "domains.economy.title": "Localized Economy", "domains.economy.description": "Localized Economy description", + "states.forbidden.title": "HK::access-denied", + "states.forbidden.description": "Localized insufficient access", "states.empty.title": "Localized empty title", "states.empty.description": "Localized empty description", }; @@ -29,12 +38,106 @@ const routeMocks = vi.hoisted(() => { return message; }); + const capability = (...slugs: string[]) => ({ + mode: "any" as const, + slugs, + }); + const manifests = [ + { + id: "operations", + labelKey: "pages.housekeeping.domains.operations.title", + descriptionKey: "pages.housekeeping.domains.operations.description", + iconId: "inbox", + previewHref: "/ase-next/operations", + capability: capability("admin.dashboard"), + landingRouteId: "operations.queue", + routes: [ + { + id: "operations.queue", + labelKey: "pages.housekeeping.routes.operations.queue", + href: "/ase-next/operations/queue", + capability: capability("admin.dashboard"), + }, + ], + searchProviders: [], + inboxSources: [], + widgets: [], + }, + { + id: "people", + labelKey: "pages.housekeeping.domains.people.title", + descriptionKey: "pages.housekeeping.domains.people.description", + iconId: "users", + previewHref: "/ase-next/people", + capability: capability( + "admin.users.view", + "admin.tickets.view", + "mod.cfh.view", + ), + landingRouteId: "people.users", + routes: [ + { + id: "people.users", + labelKey: "pages.housekeeping.routes.people.users", + href: "/ase-next/people/users", + capability: capability("admin.users.view"), + }, + { + id: "people.moderation", + labelKey: "pages.housekeeping.routes.people.moderation", + href: "/ase-next/people/moderation/cfh", + capability: capability("mod.cfh.view"), + }, + { + id: "people.tickets", + labelKey: "pages.housekeeping.routes.people.tickets", + href: "/ase-next/people/support/tickets", + capability: capability("admin.tickets.view"), + }, + ], + searchProviders: [], + inboxSources: [], + widgets: [], + }, + { + id: "economy", + labelKey: "pages.housekeeping.domains.economy.title", + descriptionKey: "pages.housekeeping.domains.economy.description", + iconId: "gem", + previewHref: "/ase-next/economy", + capability: capability("admin.catalog.view"), + landingRouteId: "economy.catalog", + routes: [ + { + id: "economy.catalog", + labelKey: "pages.housekeeping.routes.economy.catalog", + href: "/ase-next/economy/catalog", + capability: capability("admin.catalog.view"), + }, + ], + searchProviders: [], + inboxSources: [], + widgets: [], + }, + ]; + const handlers = manifests.flatMap((manifest) => + manifest.routes.map((route) => ({ + routeId: route.id, + render: async () => `Rendered ${route.id}`, + })), + ); + return { env: { NODE_ENV: "test" as "development" | "test" | "production", HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true, }, getHousekeepingCapabilityContext: vi.fn(), + forbidden: vi.fn((): never => { + throw new Error("NEXT_FORBIDDEN"); + }), + handlers, + manifests, getTranslations: vi.fn(async (namespace: string) => { if (namespace !== "pages.housekeeping") { throw new Error(`Unexpected namespace: ${namespace}`); @@ -53,6 +156,7 @@ const routeMocks = vi.hoisted(() => { vi.mock("@/env", () => ({ env: routeMocks.env })); vi.mock("next/navigation", () => ({ + forbidden: routeMocks.forbidden, notFound: routeMocks.notFound, redirect: routeMocks.redirect, })); @@ -62,6 +166,12 @@ vi.mock("next-intl/server", () => ({ vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext, })); +vi.mock("@/features/housekeeping/manifests", () => ({ + HOUSEKEEPING_MANIFESTS: routeMocks.manifests, +})); +vi.mock("@/features/housekeeping/route-handlers", () => ({ + HOUSEKEEPING_ROUTE_HANDLERS: routeMocks.handlers, +})); vi.mock("@/lib/db", () => { throw new Error("preview routes must not import the database"); }); @@ -78,16 +188,18 @@ vi.mock("@/app/actions", () => { throw new Error("preview routes must not import actions"); }); +import AdminNextDomainPage from "@/app/ase-next/[domain]/[[...segments]]/page"; import AdminNextDomainLayout from "@/app/ase-next/[domain]/layout"; -import AdminNextDomainPage from "@/app/ase-next/[domain]/page"; +import AdminNextForbidden from "@/app/ase-next/forbidden"; import AdminNextLayout from "@/app/ase-next/layout"; import AdminNextPage from "@/app/ase-next/page"; const routeFiles = [ "src/app/ase-next/layout.tsx", + "src/app/ase-next/forbidden.tsx", "src/app/ase-next/page.tsx", "src/app/ase-next/[domain]/layout.tsx", - "src/app/ase-next/[domain]/page.tsx", + "src/app/ase-next/[domain]/[[...segments]]/page.tsx", ] as const; const forbiddenModuleRoots = [ @@ -402,54 +514,67 @@ describe("/ase-next preview gate", () => { ); }); -describe("/ase-next first visible domain", () => { +describe("/ase-next forbidden boundary", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("redirects an administrator to Operations in locked registry order", async () => { + it("renders localized access denial without sensitive details", async () => { + const html = await renderRoute(AdminNextForbidden()); + + expect(html).toContain("HK::access-denied"); + expect(html).toContain("Localized insufficient access"); + expect(html).toContain('href="/"'); + expect(html).toContain("HK::back-to-site"); + expect(html).not.toMatch(/admin\.[a-z.]+|stack|database/i); + }); +}); +describe("/ase-next first accessible route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("redirects an administrator to the Operations landing route", async () => { routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]), ); await expect(AdminNextPage()).rejects.toThrow( - "NEXT_REDIRECT:/ase-next/operations", + "NEXT_REDIRECT:/ase-next/operations/queue", + ); + expect(routeMocks.redirect).toHaveBeenCalledWith( + "/ase-next/operations/queue", ); - expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/operations"); expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( 1, ); expect(routeMocks.getTranslations).not.toHaveBeenCalled(); }); - it("redirects a moderator with only an approved mod view capability to People", async () => { + it("redirects a moderator to the first accessible People route", async () => { routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( capabilityContext([PERMS.MOD_CFH_VIEW]), ); await expect(AdminNextPage()).rejects.toThrow( - "NEXT_REDIRECT:/ase-next/people", + "NEXT_REDIRECT:/ase-next/people/moderation/cfh", ); - expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people"); - expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( - 1, + expect(routeMocks.redirect).toHaveBeenCalledWith( + "/ase-next/people/moderation/cfh", ); }); - it("returns 404 when the operator has no visible domain", async () => { + it("returns forbidden when the operator has no accessible route", async () => { routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( capabilityContext([]), ); - await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND"); - expect(routeMocks.notFound).toHaveBeenCalledTimes(1); + await expect(AdminNextPage()).rejects.toThrow("NEXT_FORBIDDEN"); + expect(routeMocks.forbidden).toHaveBeenCalledTimes(1); + expect(routeMocks.notFound).not.toHaveBeenCalled(); expect(routeMocks.redirect).not.toHaveBeenCalled(); - expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( - 1, - ); }); }); - describe("/ase-next/[domain] layout", () => { beforeEach(() => { vi.clearAllMocks(); @@ -466,7 +591,7 @@ describe("/ase-next/[domain] layout", () => { expect(routeMocks.getTranslations).not.toHaveBeenCalled(); }); - it("rejects a known domain that the operator cannot access", async () => { + it("returns forbidden for a known domain the operator cannot access", async () => { routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( capabilityContext([PERMS.MOD_CFH_VIEW]), ); @@ -476,14 +601,13 @@ describe("/ase-next/[domain] layout", () => { children: createElement("p", null, "Economy body"), params: Promise.resolve({ domain: "economy" }), }), - ).rejects.toThrow("NEXT_NOT_FOUND"); - expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( - 1, - ); + ).rejects.toThrow("NEXT_FORBIDDEN"); + expect(routeMocks.forbidden).toHaveBeenCalledTimes(1); + expect(routeMocks.notFound).not.toHaveBeenCalled(); expect(routeMocks.getTranslations).not.toHaveBeenCalled(); }); - it("renders the shell without exposing a domain that has no concrete routes", async () => { + it("renders a localized shell from one refreshed capability context", async () => { routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( capabilityContext([PERMS.MOD_CFH_VIEW]), ); @@ -496,59 +620,98 @@ describe("/ase-next/[domain] layout", () => { ); expect(html).toContain("refreshed-moderator"); - expect(html).toContain("HK::skip-to-content"); - expect(html).toContain("HK::primary-navigation"); - expect(html).toContain("HK::contextual-navigation"); - expect(html).toContain("HK::command-disabled"); - expect(html).toContain("HK::preview-badge"); - expect(html).toContain("HK::back-to-site"); - expect(html).not.toContain("HK::people-title"); + expect(html).toContain("HK::people-title"); + expect(html).toContain("HK::people-moderation"); 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", - ); expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( 1, ); expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1); }); }); - -describe("/ase-next/[domain] page", () => { +describe("/ase-next/[domain]/[[...segments]] page", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("renders the real localized manifest and empty state without reloading access", async () => { - const html = await renderRoute( - AdminNextDomainPage({ - params: Promise.resolve({ domain: "people" }), - }), - ); - - expect(html).toContain("HK::people-title"); - expect(html).toContain("Localized People description"); - expect(html).toContain("Localized empty title"); - expect(html).toContain("Localized empty description"); - expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled(); - expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1); - }); - - it("rejects an unknown domain before translating", async () => { + it("rejects an unknown domain before loading capability context", async () => { await expect( AdminNextDomainPage({ - params: Promise.resolve({ domain: "unknown" }), + params: Promise.resolve({ domain: "unknown", segments: ["users"] }), }), ).rejects.toThrow("NEXT_NOT_FOUND"); expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled(); - expect(routeMocks.getTranslations).not.toHaveBeenCalled(); + }); + + it("redirects a bare domain to its accessible handled landing page", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.USERS_VIEW]), + ); + + await expect( + AdminNextDomainPage({ + params: Promise.resolve({ domain: "people", segments: [] }), + }), + ).rejects.toThrow("NEXT_REDIRECT:/ase-next/people/users"); + expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people/users"); + }); + + it("falls back to the first accessible handled route", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.TICKETS_VIEW]), + ); + + await expect( + AdminNextDomainPage({ + params: Promise.resolve({ domain: "people", segments: [] }), + }), + ).rejects.toThrow("NEXT_REDIRECT:/ase-next/people/support/tickets"); + }); + + it("renders a known permitted handled route", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.USERS_VIEW]), + ); + + const html = await renderRoute( + AdminNextDomainPage({ + params: Promise.resolve({ domain: "people", segments: ["users"] }), + }), + ); + + expect(html).toContain("Rendered people.users"); + expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1); + }); + + it("returns forbidden for a known route without capability", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.TICKETS_VIEW]), + ); + + await expect( + AdminNextDomainPage({ + params: Promise.resolve({ domain: "people", segments: ["users"] }), + }), + ).rejects.toThrow("NEXT_FORBIDDEN"); + expect(routeMocks.forbidden).toHaveBeenCalledTimes(1); + expect(routeMocks.notFound).not.toHaveBeenCalled(); + }); + + it("returns not found for an unknown path", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.USERS_VIEW]), + ); + + await expect( + AdminNextDomainPage({ + params: Promise.resolve({ domain: "people", segments: ["missing"] }), + }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + expect(routeMocks.notFound).toHaveBeenCalledTimes(1); + expect(routeMocks.forbidden).not.toHaveBeenCalled(); }); }); - describe("preview route import boundary", () => { it("rejects normalized forbidden imports and legacy chrome in real routes", () => { for (const path of routeFiles) { @@ -702,8 +865,8 @@ describe("preview route import boundary", () => { ], [ "nested template-expression dynamic action import", - "src/app/ase-next/[domain]/page.tsx", - `const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`, + "src/app/ase-next/[domain]/[[...segments]]/page.tsx", + `const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../../actions/nested")}\` : ""}\`;`, "src/actions/nested", ], [ @@ -762,8 +925,8 @@ describe("preview route import boundary", () => { ], [ "domain relative permissions export", - "src/app/ase-next/[domain]/page.tsx", - 'export { getAdminContext } from "../../../lib/permissions";', + "src/app/ase-next/[domain]/[[...segments]]/page.tsx", + 'export { getAdminContext } from "../../../../lib/permissions";', "src/lib/permissions", ], [ diff --git a/src/messages/en.json b/src/messages/en.json index 3840db69..e2ac0ba0 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -3318,6 +3318,10 @@ "error": { "title": "Unable to load housekeeping", "description": "Try again later or contact an administrator." + }, + "forbidden": { + "title": "Access denied", + "description": "Your account does not have permission to use this housekeeping area." } } } diff --git a/src/messages/it.json b/src/messages/it.json index 307cb530..45375e86 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -3317,6 +3317,10 @@ "error": { "title": "Impossibile caricare housekeeping", "description": "Riprova più tardi o contatta un amministratore." + }, + "forbidden": { + "title": "Accesso negato", + "description": "Il tuo account non dispone dei permessi necessari per usare questa area di housekeeping." } } }