mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The Module hub rendered live switches for the delegated keys cycle and coach, but the gate resolves those from their own sources (cycle from CycleProfile, coach from the operator flag + disableCoach), so a toggle wrote an inert entry to modulePreferencesJson, snapped back, and still fired a success toast. Render delegated modules as read-only rows that deep-link to their real control, strip delegated keys from the modules PATCH schema so no inert value is stored, drop the duplicate cycle control, and surface an operator-disabled state with the previously orphaned hint string.
217 lines
9.1 KiB
TypeScript
217 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch";
|
|
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import type { TimeFormatPreference } from "@/lib/format-locale";
|
|
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
|
|
import type { ModuleKey } from "@/lib/modules/registry";
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
username: string;
|
|
email: string | null;
|
|
role: string;
|
|
heightCm: number | null;
|
|
dateOfBirth: string | null;
|
|
gender: string | null;
|
|
timezone: string;
|
|
onboardingCompletedAt: string | null;
|
|
/**
|
|
* v1.4.15 Phase B5: whether the user has finished or dismissed
|
|
* the spotlight tour overlaid on the dashboard. Distinct from
|
|
* `onboardingCompletedAt` (the wizard at /onboarding) — see the
|
|
* `<TourLauncher>` component for the gating logic.
|
|
*/
|
|
onboardingTourCompleted: boolean;
|
|
/**
|
|
* v1.5.5 — relative URL of the user's self-hosted avatar, served
|
|
* from `/api/user/avatar/{id}?v={updatedAtMs}`. Replaces the
|
|
* Gravatar leak; null when the user has not uploaded an avatar
|
|
* yet (clients paint the username-initials fallback).
|
|
*/
|
|
avatarUrl: string | null;
|
|
glucoseUnit: string | null;
|
|
/**
|
|
* v1.7.0 — global metric/imperial display preference. Canonical
|
|
* storage stays SI; this selects the display-time transform branch
|
|
* (km/h vs mph, km vs mi). Null on a stale /me payload coerces to
|
|
* "metric" in `fetchMe`.
|
|
*/
|
|
unitPreference: "metric" | "imperial";
|
|
/**
|
|
* Hour-cycle display preference. AUTO follows the locale convention
|
|
* (en → AM/PM, de → 24h); H12 / H24 pin the cycle regardless of locale.
|
|
* Mirrored into localStorage by `fetchMe` so `useFormatters()` and the
|
|
* legacy `src/lib/format.ts` helpers render the same clock. Coerced to
|
|
* "AUTO" against a stale /me payload.
|
|
*/
|
|
timeFormat: TimeFormatPreference;
|
|
/**
|
|
* v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach
|
|
* mount point (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the
|
|
* inline `<CoachLaunchButton>` pill, the `/targets` page CTA)
|
|
* renders nothing. The gate sits BELOW the operator-level
|
|
* `flags.coach` short-circuit — both must agree to render the
|
|
* affordance. Defaults to `false` when the field is absent (e.g.
|
|
* stale /me payload from a partial-deploy rollback).
|
|
*/
|
|
disableCoach: boolean;
|
|
/**
|
|
* v1.7.0 — optional patient-identity fields used by the health-record
|
|
* export (PDF cover + FHIR Patient). All optional; `insuranceNumber`
|
|
* is the German KVNR, decrypted server-side for the form prefill.
|
|
*/
|
|
fullName: string | null;
|
|
insurerName: string | null;
|
|
/**
|
|
* v1.8.6 — optional German insurer institution number (IKNR, 9 digits).
|
|
* Surfaced on the FHIR `Coverage` resource's payor Organization.
|
|
*/
|
|
insurerIkNumber: string | null;
|
|
insuranceNumber: string | null;
|
|
/**
|
|
* v1.15.0 — cycle-tracking feature gate, resolved server-side from gender
|
|
* + the per-user opt-in. The cycle nav entry + page hide when false; every
|
|
* `/api/cycle/*` route also enforces the gate. Coerced to `false` against a
|
|
* stale /me payload (older server image without the field) in `fetchMe`.
|
|
*/
|
|
cycleTrackingEnabled: boolean;
|
|
/**
|
|
* v1.18.0 — the resolved per-user module enable/disable map. Each
|
|
* toggleable module key (mood, sleep, glucose, workouts, recovery, labs,
|
|
* achievements, coach, insights, doctorReport, cycle) is `true` when the
|
|
* module is enabled for this account, `false` when disabled. `cycle` and
|
|
* `coach` are delegated server-side (gender + opt-in / operator flag +
|
|
* opt-out) and already reflected here, so nav + Insights pill gates read
|
|
* this map rather than re-deriving. Coerced to `{}` against a stale /me
|
|
* payload (older server image without the field) so every gate fails open
|
|
* — a missing map never blanks the nav. Optional so existing `AuthUser`
|
|
* test fixtures stay valid; `fetchMe` always populates it (empty map when
|
|
* absent), so live code reads a real map.
|
|
*/
|
|
modules?: Partial<Record<ModuleKey, boolean>>;
|
|
/**
|
|
* v1.18.0 — operator-layer availability per toggleable module. `false`
|
|
* ⇒ the operator disabled the module server-wide (off for every account,
|
|
* regardless of personal preference). Distinct from `modules`, which is
|
|
* the already-AND-ed effective state and cannot tell operator-off from
|
|
* user-off apart. Only the Modules hub needs this distinction (to render
|
|
* an operator-disabled module as a read-only "disabled server-wide" row);
|
|
* every other gate reads `modules`. Coerced to `{}` against a stale /me
|
|
* payload so a missing map reads as all-available.
|
|
*/
|
|
moduleAvailability?: Partial<Record<ModuleKey, boolean>>;
|
|
}
|
|
|
|
async function fetchMe(): Promise<AuthUser> {
|
|
// v1.16.4 — routed through the typed wrapper; a non-OK /me (401)
|
|
// throws `ApiError`, which `useAuth` treats as "not authenticated"
|
|
// exactly like the old hand-rolled throw.
|
|
//
|
|
// v1.4.47 W3 — coerce `disableCoach` against `undefined` so a stale
|
|
// /me payload from a partial-deploy rollback (older server image
|
|
// without the field) keeps the Coach surface visible by default.
|
|
const data = await apiGet<
|
|
Partial<AuthUser> & {
|
|
id: string;
|
|
username: string;
|
|
role: string;
|
|
timezone: string;
|
|
}
|
|
>("/api/auth/me");
|
|
// Hour-cycle preference: coerce against a stale /me payload, then mirror
|
|
// into localStorage so `useFormatters()` and the legacy format helpers
|
|
// (which cannot reach the query cache) render the same clock.
|
|
const timeFormat: TimeFormatPreference = isTimeFormatPreference(
|
|
data.timeFormat,
|
|
)
|
|
? data.timeFormat
|
|
: "AUTO";
|
|
storeTimeFormat(timeFormat);
|
|
return {
|
|
...(data as AuthUser),
|
|
disableCoach: data.disableCoach ?? false,
|
|
// v1.7.0 — coerce against a stale /me payload (older server image
|
|
// without the field) so the display defaults to metric.
|
|
unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric",
|
|
timeFormat,
|
|
// v1.15.0 — coerce against a stale /me payload so the cycle nav entry
|
|
// stays hidden by default when the field is absent.
|
|
cycleTrackingEnabled: data.cycleTrackingEnabled === true,
|
|
// v1.18.0 — coerce against a stale /me payload (older server image
|
|
// without the module map) to an empty map so every module gate fails
|
|
// open: a missing key reads as enabled and the nav stays intact.
|
|
modules:
|
|
data.modules && typeof data.modules === "object" ? data.modules : {},
|
|
// v1.18.0 — operator availability map, coerced against a stale payload
|
|
// to an empty map (all-available) so the Modules hub never shows a
|
|
// spurious "disabled server-wide" row on an older server image.
|
|
moduleAvailability:
|
|
data.moduleAvailability && typeof data.moduleAvailability === "object"
|
|
? data.moduleAvailability
|
|
: {},
|
|
};
|
|
}
|
|
|
|
export function useAuth() {
|
|
const query = useQuery({
|
|
// v1.4.40 W-RSC — factory-routed to `queryKeys.authMe()`. Pre-fix
|
|
// the literal `["auth", "me"]` was the canonical example in
|
|
// audit-H1 of factory drift. The prefix `["auth"]` still matches
|
|
// `queryKeys.auth()` invalidations.
|
|
queryKey: queryKeys.authMe(),
|
|
queryFn: fetchMe,
|
|
// v1.16.8 — one retry on network errors / 5xx (never 401/403). A
|
|
// single transient failure used to flip `isAuthenticated` false and
|
|
// send the shell to the redirect spinner mid-session.
|
|
retry: retryOnceOnTransientError,
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
return {
|
|
user: query.data ?? null,
|
|
isLoading: query.isLoading,
|
|
isAuthenticated: !!query.data,
|
|
error: query.error,
|
|
refetch: query.refetch,
|
|
};
|
|
}
|
|
|
|
export function useLogout() {
|
|
const queryClient = useQueryClient();
|
|
const router = useRouter();
|
|
const { t } = useTranslations();
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.setQueryData(queryKeys.authMe(), null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
|
// Defense-in-depth at the session boundary: wipe the service-worker
|
|
// page cache so no cached navigation HTML survives a logout on a
|
|
// shared device. Scoped to `healthlog-pages-*` only — the static
|
|
// cache (hashed chunks, icons) carries no PII and dropping it would
|
|
// force a needless re-download on the next login. Best-effort; never
|
|
// blocks the redirect.
|
|
if (typeof caches !== "undefined") {
|
|
void caches.keys().then((keys) => {
|
|
for (const key of keys) {
|
|
if (key.startsWith("healthlog-pages-")) void caches.delete(key);
|
|
}
|
|
});
|
|
}
|
|
router.push("/auth/login");
|
|
},
|
|
// v1.16.4 — a network-failed logout used to do nothing at all (the
|
|
// menu closed, the session stayed); a toast names the failure.
|
|
onError: () => toast.error(t("common.networkError")),
|
|
});
|
|
}
|