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 };
+}