From d8dc6ca8a74fa9a1533e45139df6be31a2706376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 10:18:04 +0200 Subject: [PATCH] fix(settings): answer a switched-off layout subpage with the module notice /settings/layout/ is a URL a person can land on, so with its module off it now shows the module notice in place, like every module page, instead of bouncing to the Layout hub without saying why. --- .../__tests__/layout-module-gate.test.tsx | 57 +++++++++++++++++++ .../settings/layout-module-gate.tsx | 50 +++++++++------- 2 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 src/components/settings/__tests__/layout-module-gate.test.tsx diff --git a/src/components/settings/__tests__/layout-module-gate.test.tsx b/src/components/settings/__tests__/layout-module-gate.test.tsx new file mode 100644 index 000000000..d0920458d --- /dev/null +++ b/src/components/settings/__tests__/layout-module-gate.test.tsx @@ -0,0 +1,57 @@ +/** + * `/settings/layout/` is a real URL (a bookmark, the hub's own link, + * a shared screenshot), so with its module off it answers in place with the + * module notice, like every module page, rather than bouncing to the hub. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const replace = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +const userRef: { + value: { + modules: Record; + moduleAccess?: Record; + }; +} = { value: { modules: {} } }; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: userRef.value }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { LayoutModuleGate } from "../layout-module-gate"; + +function render(moduleKey: "mood" | undefined) { + return renderToStaticMarkup( + + +

section

+
+
, + ); +} + +describe("", () => { + it("renders the section while its module is on, and always for an unowned group", () => { + userRef.value = { modules: { mood: true } }; + expect(render("mood")).toContain('data-testid="section"'); + userRef.value = { modules: { mood: false } }; + expect(render(undefined)).toContain('data-testid="section"'); + }); + + it("answers in place with the module notice when its module is off, never redirecting", () => { + userRef.value = { + modules: { mood: false }, + moduleAccess: { mood: "disabled" }, + }; + const html = render("mood"); + expect(html).not.toContain('data-testid="section"'); + expect(html).toContain('data-slot="module-disabled-notice"'); + expect(html).toContain('data-module="mood"'); + expect(html).toContain('data-slot="module-off-open-settings"'); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings/layout-module-gate.tsx b/src/components/settings/layout-module-gate.tsx index 0f3e73a03..1e5bb3465 100644 --- a/src/components/settings/layout-module-gate.tsx +++ b/src/components/settings/layout-module-gate.tsx @@ -1,26 +1,29 @@ "use client"; import * as React from "react"; -import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice"; +import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; import type { ModuleKey } from "@/lib/modules/registry"; /** - * v1.25.11 (#148) — client gate for an Appearance subpage - * (`/settings/layout/`). + * Client gate for an Appearance subpage (`/settings/layout/`). * - * A module's subpage may be reached by direct URL even when the user has the - * module turned off. This wrapper fails OPEN: it renders the section unless the - * resolved `useAuth().user.modules` map has the gate key explicitly `false`, - * in which case it bounces back to the hub (`/settings/layout`). + * The subpage is a real URL a person can land on (a bookmark, the hub's own + * link, a link somebody sent) with its module switched off. It answers in + * place with the module notice, like every module page (`ModulePageGate`), + * rather than bouncing to the hub, which dropped the reader somewhere else + * without saying why. The notice names the reason from `moduleAccess` and + * offers Settings, Modules only when the record's own switch is off. * - * The gate is hydration-stable: `useMounted()` returns `false` during SSR AND - * the first client paint, so the first render ALWAYS shows the section - * (matching the server HTML); the real check applies once, after hydration, as - * an ordinary client update. The redirect is a client-only effect, so it never - * diverges the initial render and cannot trigger a React #418 mismatch. + * Fails OPEN and is hydration-stable: `useMounted()` is `false` during SSR + * and the first client paint, so the first render always shows the section + * (matching the server HTML); the module check applies once, after + * hydration, as an ordinary client update. * * Groups the surface map gives no owner (dashboard / insights / vorsorge) * pass `moduleKey={undefined}` and always render. @@ -34,17 +37,22 @@ export function LayoutModuleGate({ }) { const hydrated = useMounted(); const { user } = useAuth(); - const router = useRouter(); + const { t } = useTranslations(); const disabled = hydrated && moduleKey !== undefined && user?.modules?.[moduleKey] === false; - React.useEffect(() => { - if (disabled) { - router.replace("/settings/layout"); - } - }, [disabled, router]); - - if (disabled) return null; - return <>{children}; + if (!disabled || moduleKey === undefined) return <>{children}; + return ( + + + {t("moduleOff.openModules")} + + + } + /> + ); }