mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(insights): guard the module sub-pages against direct-URL hits
The sleep, blood-glucose, workouts, and recovery insights pages fetched their series and half-rendered when the backing module was disabled — a disabled account that navigated straight to the URL saw the surface flash before any guard. They now resolve a shared useModulePageGuard hook (mirroring the cycle page): an unauthenticated visitor bounces to login, a disabled-module account bounces to /insights, and the page renders a calm loader until the gate clears. The backing /api routes enforce the gate server-side, so this is the UX layer, not the security boundary.
This commit is contained in:
1 parent
6eef3ab21c
commit
a524cad4c4
7 files changed
+194
-3
No files matched your search
@@ -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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BLOOD_GLUCOSE"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { RecoverySection } from "@/components/insights/recovery/recovery-section";
|
||||
|
||||
@@ -20,6 +23,16 @@ import { RecoverySection } from "@/components/insights/recovery/recovery-section
|
||||
*/
|
||||
export default function InsightsRecoveryPage() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("recovery");
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-recovery account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
import { Loader2, Moon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -35,9 +36,20 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
export default function InsightsSchlafPage() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const { ready } = useModulePageGuard("sleep");
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
|
||||
// /insights instead of half-rendering the sleep surface.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkoutDetail } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
@@ -32,8 +35,18 @@ export default function InsightsWorkoutDetailPage({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, error } = useWorkoutDetail(id);
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Activity } from "lucide-react";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { WorkoutList } from "@/components/insights/workout-list";
|
||||
@@ -28,8 +29,18 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
*/
|
||||
export default function InsightsWorkoutsPage() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.18.0 B1 — `useModulePageGuard` is the client-side direct-URL guard
|
||||
* for the insights sub-pages of a toggleable module. The repo has no
|
||||
* `@testing-library/react`, so the contract is exercised through an SSR
|
||||
* Probe that renders the resolved `ready` flag. The redirect side-effect
|
||||
* lives in a `useEffect` (no-op under SSR); the `ready` derivation is the
|
||||
* load-bearing decision both the redirect and the loader branch read, so
|
||||
* locking it covers the guard.
|
||||
*/
|
||||
const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
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 <span data-testid="ready">{String(ready)}</span>;
|
||||
}
|
||||
|
||||
function readyOf(): string {
|
||||
const html = renderToStaticMarkup(<Probe moduleKey="glucose" />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user