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")} + + + } + /> + ); }