diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index e753ad589..22b2c3a9b 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { Droplet } from "lucide-react"; +import { Droplet, Loader2 } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel"; import { useAuth } from "@/hooks/use-auth"; +import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { useTranslations } from "@/lib/i18n/context"; import { resolveGlucoseUnit } from "@/lib/glucose"; @@ -29,9 +30,19 @@ const MGDL_PER_MMOL = 18.0182; export default function InsightsBlutzuckerPage() { const { user } = useAuth(); const { t } = useTranslations(); + const { ready } = useModulePageGuard("glucose"); const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); const isMmol = glucoseUnit === "mmol/L"; + // v1.18.0 B1 — bounce a direct URL hit on a disabled-glucose account. + if (!ready) { + return ( +
+ +
+ ); + } + return ( + + + ); + } return ( + + + ); + } + if (isEmpty) { return ( + + + ); + } + return ( + + + ); + } + return ( ({ + useRouter: () => ({ push: pushMock }), +})); + +const useAuthMock = vi.fn(); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => useAuthMock(), +})); + +import { useModulePageGuard } from "../use-module-page-guard"; + +function Probe({ moduleKey }: { moduleKey: "glucose" }) { + const { ready } = useModulePageGuard(moduleKey); + return {String(ready)}; +} + +function readyOf(): string { + const html = renderToStaticMarkup(); + return html.includes(">true<") ? "true" : "false"; +} + +describe("useModulePageGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("is not ready while auth is loading", () => { + useAuthMock.mockReturnValue({ + user: null, + isLoading: true, + isAuthenticated: false, + }); + expect(readyOf()).toBe("false"); + }); + + it("is not ready when unauthenticated", () => { + useAuthMock.mockReturnValue({ + user: null, + isLoading: false, + isAuthenticated: false, + }); + expect(readyOf()).toBe("false"); + }); + + it("is not ready when the module is disabled (explicit false)", () => { + useAuthMock.mockReturnValue({ + user: { modules: { glucose: false } }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("false"); + }); + + it("is ready when authenticated and the module is enabled", () => { + useAuthMock.mockReturnValue({ + user: { modules: { glucose: true } }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("true"); + }); + + it("is ready (default-on) when the module key is absent", () => { + useAuthMock.mockReturnValue({ + user: { modules: {} }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("true"); + }); +}); diff --git a/src/hooks/use-module-page-guard.ts b/src/hooks/use-module-page-guard.ts new file mode 100644 index 000000000..758d908f4 --- /dev/null +++ b/src/hooks/use-module-page-guard.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { useAuth } from "@/hooks/use-auth"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** + * v1.18.0 B1 — client-side page guard for a toggleable module. + * + * Mirrors the cycle page (`src/app/cycle/page.tsx`): an unauthenticated + * visitor bounces to login; an authenticated account without the module + * enabled bounces to `/insights`. The nav entry is already hidden for a + * disabled account, so this only catches a direct URL hit. Every backing + * `/api/*` route enforces the gate server-side, so this is a UX redirect, + * not the security boundary. + * + * Returns `ready` — `true` only once auth has resolved, the visitor is + * authenticated, AND the module is enabled. While `false` the caller + * should render a calm loader / nothing rather than the (about-to- + * redirect) module surface, so a disabled-module page never half-renders. + * + * Default-on (disabled allowlist): an absent map, a missing key, or any + * non-`false` value reads as enabled — matching the server gate. + */ +export function useModulePageGuard(moduleKey: ModuleKey): { + ready: boolean; +} { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.modules?.[moduleKey] !== false; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/insights"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + return { ready: !isLoading && isAuthenticated && enabled }; +}