mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
- /api/auth/me and the AuthUser type described disableCoach as the gate every Coach surface checks below a flags.coach short-circuit. Neither has been true since the AI capability resolution: it is the account's stored opt-out, and surfaces read ai.capabilities.coach or modules.coach. - /api/mcp/tokens claimed /api/tokens mints medication:ingest; that mint was removed, and the medication pair now comes from the per-medication endpoint toggle. - /api/meta/capabilities listed the token kinds as wildcard, medication ingest or MCP, missing measurements:write, workouts:write and documents:write. Comment only: the served scopeMintable answer for the FHIR face is unchanged and still right.
631 lines
29 KiB
TypeScript
631 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useQuery,
|
|
useMutation,
|
|
useQueryClient,
|
|
type QueryClient,
|
|
} from "@tanstack/react-query";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch";
|
|
import { adoptRecordFenceState } from "@/lib/api/record-fence";
|
|
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
|
import { useMounted } from "@/hooks/use-mounted";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import type {
|
|
TimeFormatPreference,
|
|
DateFormatPreference,
|
|
} from "@/lib/format-locale";
|
|
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
|
|
import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format";
|
|
import { storeTimezone } from "@/lib/timezone-mirror";
|
|
import type { ModuleKey } from "@/lib/modules/registry";
|
|
import type { AiCapabilities } from "@/lib/ai/capabilities/types";
|
|
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
|
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
|
import type { TourProgress } from "@/lib/onboarding/tour-progress";
|
|
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
|
import {
|
|
setRecordScope,
|
|
setRefusedRecordScope,
|
|
} from "@/lib/query-keys/record-scope";
|
|
import {
|
|
settleRecordSessionTransition,
|
|
settleRefusedRecordSessionTransition,
|
|
} from "@/lib/query-keys/record-session-transition";
|
|
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
|
import { parseAccountAccess } from "@/lib/sharing/account-access-schema";
|
|
|
|
/**
|
|
* v1.36.0 — an account with no sharing at all. The shape `fetchMe` falls back
|
|
* to when the payload carries no block, so every consumer reads a real object
|
|
* and none of them has to spell out what a missing block would mean.
|
|
*/
|
|
export const NO_ACCOUNT_ACCESS: AccountAccess = {
|
|
accounts: [],
|
|
active: null,
|
|
recordKind: "self",
|
|
canSwitch: false,
|
|
};
|
|
|
|
/** Whether the account-access block was absent, valid, or refused as malformed. */
|
|
export type AccountAccessStatus = "absent" | "valid" | "invalid";
|
|
|
|
/**
|
|
* v1.18.6 — resume point for the module tour as the client sees it on
|
|
* `/api/auth/me`. Mirrors the server-side `TourProgress` shape.
|
|
*/
|
|
export type AuthTourProgress = TourProgress;
|
|
|
|
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.18.6 — resumable module-tour progress, or null when the user
|
|
* has not started the tour. The `<TourLauncher>` seeds its resume
|
|
* index from `lastStopId`. Distinct from the coarse
|
|
* `onboardingTourCompleted` boolean, which stays the auto-launch gate.
|
|
*/
|
|
onboardingTourProgress: AuthTourProgress | null;
|
|
/**
|
|
* v1.39 (C1) — the needs-based setup flow's state for the record the payload
|
|
* describes — the active one under a switch: the nine ordered steps with
|
|
* their `pending | done | skipped` status, the answers as given, the flow's
|
|
* own completion stamp (distinct from `onboardingCompletedAt` above, which
|
|
* stays the first-run redirect's gate) and the one task the flow offered.
|
|
* Under a scoped grant the answers come back empty.
|
|
*
|
|
* Optional in the type so a stale /me payload — an older server image, or a
|
|
* test fixture written before the field existed — coerces to "no flow" in
|
|
* the mapper below rather than failing the shape.
|
|
*/
|
|
onboarding?: OnboardingStateDto | null;
|
|
/**
|
|
* v1.18.6 (DISC-02) — ISO timestamp of the one-time medical-disclaimer
|
|
* acknowledgment, or null when never acknowledged. The onboarding welcome
|
|
* step gates "Get started" on a non-null value for a fresh account.
|
|
* Optional in the type so a stale /me payload (older server image without
|
|
* the field) and existing test fixtures coerce to "never acknowledged"
|
|
* rather than failing the shape.
|
|
*/
|
|
disclaimerAcknowledgedAt?: string | null;
|
|
/**
|
|
* The disclaimer version that acknowledgment was given for. The welcome
|
|
* gate compares it against the current `DISCLAIMER_VERSION` so a revised
|
|
* disclaimer re-prompts instead of riding an old acknowledgment. Optional
|
|
* for the same stale-payload reason as the timestamp above.
|
|
*/
|
|
disclaimerAcknowledgedVersion?: string | null;
|
|
/**
|
|
* 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;
|
|
/**
|
|
* Date-order display preference. AUTO follows the locale convention
|
|
* (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY / MDY / YMD pin the field order
|
|
* regardless of locale. Mirrored into localStorage by `fetchMe` so
|
|
* `useFormatters()` and the `<DateField>` primitive render the same order.
|
|
* Coerced to "AUTO" against a stale /me payload.
|
|
*/
|
|
dateFormat: DateFormatPreference;
|
|
/**
|
|
* v1.4.47 W3 — the signed-in person's own stored Coach opt-out. It is
|
|
* the value the Activate-Coach switch and the module toggle edit, and
|
|
* nothing else should read it: it belongs to the account, not to the
|
|
* record on screen, and it is only one of the layers that decide whether
|
|
* the Coach is there. Every Coach surface gates on the resolved answers
|
|
* instead — `ai.capabilities.coach` (`useAiCapability("coach")`) for
|
|
* the affordances, `modules.coach` for the Coach's settings and tuning.
|
|
* Defaults to `false` when the field is absent (a stale /me payload).
|
|
*/
|
|
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;
|
|
/**
|
|
* Practice / clinic name from the last health-record export, so the export
|
|
* panel opens pre-filled instead of asking for it again every month. The
|
|
* export route persists it after a successful generation; `null` means the
|
|
* user has never set one, or deliberately cleared the line.
|
|
*/
|
|
lastReportPracticeName: string | null;
|
|
/**
|
|
* The saved report selection blob (`{ v, leaves, format, rangeDays,
|
|
* includeCharts }`), or `null` when the account has never saved one. Parsed
|
|
* through `parseSavedProfile` at the one place that reads it, so a drifted
|
|
* shape resolves to "never saved" rather than to a partial scope.
|
|
*/
|
|
reportSelection: unknown;
|
|
/**
|
|
* 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>>;
|
|
/**
|
|
* Why each module is or is not available for the record this browser is
|
|
* inside — `"enabled"`, `"disabled"` (the record has it switched off),
|
|
* `"not_granted"` (the active grant does not open the module's section) or
|
|
* `"unavailable"` (the operator switched it off for the whole instance).
|
|
* Precedence outside-in: unavailable > not_granted > disabled > enabled.
|
|
*
|
|
* Additive beside `modules`, never instead of it: `modules[key]` is exactly
|
|
* `moduleAccess[key] === "enabled"`, so every gate keeps reading the boolean
|
|
* and only the surfaces that have to EXPLAIN a missing module read this. A
|
|
* client holding the boolean alone has to guess between three different
|
|
* reasons, which is how an empty state ends up offering a delegate a switch
|
|
* they cannot reach. Coerced to `{}` against a stale /me payload, and a
|
|
* missing key falls back to the boolean.
|
|
*/
|
|
moduleAccess?: Partial<Record<ModuleKey, ModuleAccessState>>;
|
|
/**
|
|
* v1.39 — the AI capabilities for the record this browser is inside, and
|
|
* the account's provider state, resolved on the server. Read it through
|
|
* `useAiCapability`, never directly: the hook is where "not loaded yet" and
|
|
* "not published by this server" become "unavailable", so nothing fires a
|
|
* model request or flashes AI chrome before the answer is known. `null`
|
|
* against a payload that does not carry a well-formed block.
|
|
*/
|
|
ai?: AiCapabilities | null;
|
|
/**
|
|
* v1.36.0 — account sharing, resolved server-side. `accounts` is the
|
|
* switcher's menu, `active` is the record this browser is inside (null when
|
|
* it is in its own), `canSwitch` and per-entry `canWrite` are booleans to
|
|
* bind, never to compute.
|
|
*
|
|
* Nothing in the UI may derive a permission from this block: the server
|
|
* publishes what the caller may do and the client renders it, because two
|
|
* programs deciding one person's access is how they end up disagreeing.
|
|
*
|
|
* Optional so existing `AuthUser` test fixtures stay valid; `fetchMe`
|
|
* always populates it, coercing an absent block (an older server image) to
|
|
* the empty one — no accounts, no active record, no switcher.
|
|
*/
|
|
accountAccess?: AccountAccess;
|
|
/**
|
|
* The parser outcome for `accountAccess`. An absent block is the only
|
|
* owner-compatible fallback: it is how older server images answered before
|
|
* sharing existed. A present malformed block stays explicitly refused so a
|
|
* server-side switch cannot paint owner controls in the target record.
|
|
*/
|
|
accountAccessStatus?: AccountAccessStatus;
|
|
/**
|
|
* v1.37.0 — the record-session context the server says this browser is in.
|
|
*
|
|
* `epoch` counts how many times this session's record selector has moved and
|
|
* `scope` names the record it is pointed at (null for one's own). Adopted by
|
|
* `fetchMe` and then attached to every same-origin request, so a request
|
|
* formed under a context that has since moved is refused rather than served
|
|
* against the wrong record.
|
|
*
|
|
* Null on the Bearer transport, which carries no such state. Optional so
|
|
* existing `AuthUser` fixtures stay valid; an absent field is an older server
|
|
* image and leaves the client unadopted, which the `bootstrap` sentinel makes
|
|
* safe.
|
|
*/
|
|
recordSession?: { epoch: number; scope: string | null } | null;
|
|
}
|
|
|
|
/**
|
|
* Non-sensitive "a session existed here" marker (localStorage boolean, no
|
|
* identity data). A cold offline launch cannot reach `/api/auth/me`, so the
|
|
* auth query errors without ever answering the session question — the marker
|
|
* is the last-known state that lets the shell render the cached app chrome
|
|
* instead of bouncing to the login gate and wiping the offline caches. Set on
|
|
* every successful `/api/auth/me`, cleared on every session END (logout and
|
|
* true 401/403).
|
|
*/
|
|
const WAS_AUTHENTICATED_KEY = "healthlog-was-authenticated";
|
|
|
|
export function readWasAuthenticated(): boolean {
|
|
try {
|
|
return localStorage.getItem(WAS_AUTHENTICATED_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function storeWasAuthenticated(): void {
|
|
try {
|
|
localStorage.setItem(WAS_AUTHENTICATED_KEY, "1");
|
|
} catch {
|
|
/* storage unavailable — the marker is best-effort */
|
|
}
|
|
}
|
|
|
|
function clearWasAuthenticated(): void {
|
|
try {
|
|
localStorage.removeItem(WAS_AUTHENTICATED_KEY);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when an `/api/auth/me` failure carries NO verdict on the session —
|
|
* a network-level failure (fetch `TypeError` offline, abort/timeout), the
|
|
* service worker's synthetic 503 offline envelope (`meta.offline`), the
|
|
* browser reporting itself offline, or a server-side 5xx. None of these mean
|
|
* "logged out"; only an explicit 401/403 does. Treating them as session end
|
|
* used to redirect an offline relaunch to /auth/login and wipe the offline
|
|
* caches — destroying exactly the installed-PWA-opened-offline scenario the
|
|
* SW + query persister exist for.
|
|
*/
|
|
export function isAuthVerdictUnknown(error: unknown): boolean {
|
|
if (error instanceof ApiError) {
|
|
// 5xx (incl. the SW's synthetic 503 with `meta.offline: true`) and any
|
|
// other non-auth status: the server never ruled on the session. Only an
|
|
// explicit 401/403 is a verdict.
|
|
return error.status !== 401 && error.status !== 403;
|
|
}
|
|
// Everything below the HTTP layer is transport-level by construction:
|
|
// fetch `TypeError` (offline / DNS / connection refused), `AbortError` /
|
|
// `TimeoutError` DOMException from the 15 s default signal.
|
|
return true;
|
|
}
|
|
|
|
export 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);
|
|
// Date-order preference: coerce against a stale /me payload, then mirror
|
|
// into localStorage so `useFormatters()` and the `<DateField>` primitive
|
|
// (which cannot reach the query cache) render the same field order.
|
|
const dateFormat: DateFormatPreference = isDateFormatPreference(
|
|
data.dateFormat,
|
|
)
|
|
? data.dateFormat
|
|
: "AUTO";
|
|
storeDateFormat(dateFormat);
|
|
// Profile timezone (issue #490): mirror into localStorage so
|
|
// `useFormatters()` and the legacy format helpers render timestamps in
|
|
// the user's zone. `storeTimezone` validates the IANA name and CLEARS
|
|
// the mirror on garbage, so a poison value falls back to Berlin instead
|
|
// of reaching `Intl.DateTimeFormat` (RangeError).
|
|
storeTimezone(typeof data.timezone === "string" ? data.timezone : "");
|
|
// A confirmed session: refresh the non-sensitive offline-relaunch marker.
|
|
storeWasAuthenticated();
|
|
// v1.36.0 — the record this browser is reading, straight from the server's
|
|
// resolved answer. This is the correcting write for the cache-scope mirror:
|
|
// whatever the switch flow stamped before its reload, the block is what the
|
|
// resolver will actually honour on the next request, so the mirror tracks it
|
|
// rather than the client's memory of what it asked for.
|
|
// v1.37.0 — adopt the record-session context BEFORE anything releases a
|
|
// hold. `/api/auth/me` is one of exactly two responses a client may learn
|
|
// its context from, and a hold released onto an unadopted context would put
|
|
// the shell back on screen with every subsequent request still asserting
|
|
// `bootstrap`. Adoption first means a released hold is always released onto
|
|
// a context the next request can prove.
|
|
adoptRecordFenceState(data.recordSession);
|
|
const { accountAccess, status: accountAccessStatus } =
|
|
resolveAccountAccess(data);
|
|
if (accountAccessStatus === "invalid") {
|
|
setRefusedRecordScope();
|
|
} else {
|
|
setRecordScope(accountAccess.active?.accountId ?? null);
|
|
}
|
|
// A cross-tab switch holds the shell until this server-resolved payload
|
|
// confirms the record the transition committed. The malformed branch has
|
|
// already installed its refusal sentinel, so it may release only into the
|
|
// refusal door rather than being mistaken for the caller's own record.
|
|
if (accountAccessStatus === "invalid") {
|
|
settleRefusedRecordSessionTransition();
|
|
} else {
|
|
settleRecordSessionTransition(accountAccess.active?.accountId ?? null);
|
|
}
|
|
return {
|
|
...(data as AuthUser),
|
|
disableCoach: data.disableCoach ?? false,
|
|
// v1.18.6 — coerce against a stale /me payload (older server image
|
|
// without the field) to null so the tour starts from the top.
|
|
onboardingTourProgress: data.onboardingTourProgress ?? null,
|
|
// v1.39 (C1) — coerce against a stale /me payload (an older server image
|
|
// without the field) to null, which every reader treats as "no flow was
|
|
// ever run" rather than as an unfinished one.
|
|
onboarding: data.onboarding ?? null,
|
|
// 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,
|
|
dateFormat,
|
|
// 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
|
|
: {},
|
|
// The reason behind each module's boolean. Coerced against a stale /me
|
|
// payload to an empty map; `useModuleAccess` then falls back to the
|
|
// boolean, so an older server image reads "disabled" rather than
|
|
// inventing a reason it was never told.
|
|
moduleAccess:
|
|
data.moduleAccess && typeof data.moduleAccess === "object"
|
|
? data.moduleAccess
|
|
: {},
|
|
// Coerced to null unless both halves are objects; the capability hook
|
|
// reads null as "unavailable", which is the safe answer for a block the
|
|
// server did not send.
|
|
ai:
|
|
data.ai &&
|
|
typeof data.ai === "object" &&
|
|
data.ai.capabilities &&
|
|
typeof data.ai.capabilities === "object" &&
|
|
data.ai.provider &&
|
|
typeof data.ai.provider === "object"
|
|
? data.ai
|
|
: null,
|
|
accountAccess,
|
|
accountAccessStatus,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read the sharing block off a payload that may not carry one.
|
|
*
|
|
* A genuinely missing field is the pre-sharing server contract and remains an
|
|
* own-record fallback. Once a server publishes the field, every other value —
|
|
* including `undefined` as an own property and `null` — is a malformed block,
|
|
* not permission to fall back to the caller's own presentation.
|
|
*/
|
|
function resolveAccountAccess(value: object): {
|
|
accountAccess: AccountAccess;
|
|
status: AccountAccessStatus;
|
|
} {
|
|
if (!Object.hasOwn(value, "accountAccess")) {
|
|
return { accountAccess: NO_ACCOUNT_ACCESS, status: "absent" };
|
|
}
|
|
|
|
const accountAccess = parseAccountAccess(
|
|
(value as { accountAccess?: unknown }).accountAccess,
|
|
);
|
|
return accountAccess
|
|
? { accountAccess, status: "valid" }
|
|
: { accountAccess: NO_ACCOUNT_ACCESS, status: "invalid" };
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
// v1.27.x offline fix — classify a failed `/api/auth/me` before acting on
|
|
// it. Only an explicit 401/403 means "session ended"; a network-level
|
|
// failure (offline relaunch, SW 503 offline envelope, timeout) or a 5xx
|
|
// carries no verdict, so the hook holds the LAST-KNOWN auth state (the
|
|
// localStorage marker) instead of flipping to unauthenticated. Pre-fix, an
|
|
// airplane-mode relaunch read as a logout: the shell redirected to
|
|
// /auth/login and wiped every offline cache (data 7→0 entries, pages 1→0
|
|
// in the audit measurement), making the whole offline story unreachable.
|
|
const verdictUnknown = query.isError && isAuthVerdictUnknown(query.error);
|
|
const isAuthenticated =
|
|
!!query.data || (verdictUnknown && readWasAuthenticated());
|
|
|
|
return {
|
|
user: query.data ?? null,
|
|
isLoading: query.isLoading,
|
|
isAuthenticated,
|
|
/**
|
|
* True while the session question is genuinely unanswered — the auth
|
|
* probe failed at the transport level (or 5xx) and no cached `/me`
|
|
* payload exists. The shell uses it to suppress the session-end cache
|
|
* wipe: a wipe without a 401/403 verdict would destroy the offline
|
|
* caches on every network blip.
|
|
*/
|
|
isAuthUnknown: !query.data && verdictUnknown,
|
|
error: query.error,
|
|
refetch: query.refetch,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The account payload, withheld from the SSR pass and the hydration render.
|
|
*
|
|
* ## The problem it solves
|
|
*
|
|
* The app shell sits in the streamed HTML shell and hydrates first; mounting it
|
|
* fires `/api/auth/me`. A page below a `loading.tsx` is a streamed Suspense
|
|
* boundary and hydrates LATER — routinely after that response has landed. React
|
|
* replays the boundary's first render as a hydration render, and at that moment
|
|
* `useAuth().user` holds a payload the server never had, because an RSC renders
|
|
* with no query cache at all. Any branch that consults the account then renders
|
|
* one thing in the streamed HTML and another in the browser, React reports #418
|
|
* and discards the whole streamed tree.
|
|
*
|
|
* That stayed invisible while every page kept its own data behind a
|
|
* `useMounted()` skeleton gate: both sides painted the same silhouette. The
|
|
* dashboard's RSC prefetch (`src/app/page.tsx`) removed the silhouette — real
|
|
* tiles render on both sides now — and the divergence surfaced as a bailout on
|
|
* every cold load of `/`, throwing away exactly the paint the prefetch buys.
|
|
*
|
|
* `useMounted()` answers with its SERVER snapshot during SSR *and* during
|
|
* hydration, then with the client one from the first re-render, so this pins the
|
|
* hydration render to what the server rendered and hands the payload over one
|
|
* render later. A component mounted by a client-side navigation never sees the
|
|
* pin (`useSyncExternalStore` consults the server snapshot only while
|
|
* hydrating), and `user === null` beside a settled query is not a new state: it
|
|
* is what every consumer already renders on a cold load, before `/me` answers.
|
|
*
|
|
* ## Why `useAuth` itself must NOT do this
|
|
*
|
|
* Tried, and it breaks the app. Several surfaces drive a REDIRECT off the
|
|
* payload — the document vault sends you to `/` when
|
|
* `user.modules.inboundDocuments` is not `true`, and its effect only waits on
|
|
* `isLoading`, which is already false by then. A hydration render that answers
|
|
* "no account" makes that effect fire for real, and the vault bounces to the
|
|
* dashboard on every load. The same shape would latch into any `useState`
|
|
* initializer seeded from the account.
|
|
*
|
|
* So the withholding is opt-in, and belongs to components that RENDER the
|
|
* account rather than navigate on it. Reach for it in anything that paints
|
|
* inside a streamed page boundary; leave the redirect gates on `useAuth`.
|
|
*/
|
|
export function useAccountOnceMounted(): AuthUser | null {
|
|
const { user } = useAuth();
|
|
const mounted = useMounted();
|
|
return mounted ? user : null;
|
|
}
|
|
|
|
/**
|
|
* Wipe every client-side cache at a session END so no session ever inherits
|
|
* another account's data on a shared browser.
|
|
*
|
|
* Primary cross-user guard: `queryClient.clear()` drops the entire IN-MEMORY
|
|
* query cache. On a long-lived SPA the root QueryClient instance outlives every
|
|
* client-side navigation, and the health-data families
|
|
* (`["measurements"]`, `["dashboard","snapshot"]`, `["labs", …]`, `["mood", …]`,
|
|
* `["insights", …]`) are NOT user-scoped — so without this wipe the next account
|
|
* that logs in on the same browser reads the previous account's cached entries
|
|
* before any refetch lands. `clear()` also drops `["auth","me"]`, superseding the
|
|
* former explicit `setQueryData(null)` + `invalidateQueries(["auth"])`.
|
|
*
|
|
* Then `clearOfflineCachesForSessionEnd()` wipes the persisted layers — the
|
|
* IndexedDB query snapshot, the SW offline read-data cache (`healthlog-data-*`),
|
|
* and the SW page cache (`healthlog-pages-*`, cached navigation HTML). The static
|
|
* cache (hashed chunks, icons) carries no PII and stays intact to avoid a
|
|
* needless re-download. Best-effort; never blocks the redirect.
|
|
*/
|
|
export function clearCachesForSessionEnd(queryClient: QueryClient): void {
|
|
queryClient.clear();
|
|
// v1.36.0 — the cache-scope mirror is session state too. Left behind, the
|
|
// next account on this browser would boot with the previous session's record
|
|
// scope, which partitions its cache under a stranger's id until the first
|
|
// `/api/auth/me` corrects it. Harmless for correctness (the store it points
|
|
// at is wiped below) and free to get right.
|
|
setRecordScope(null);
|
|
// The offline-relaunch marker is session state too: after a real session
|
|
// end the next cold offline launch must land on the login gate, not the
|
|
// cached shell.
|
|
clearWasAuthenticated();
|
|
void clearOfflineCachesForSessionEnd();
|
|
}
|
|
|
|
export function useLogout() {
|
|
const queryClient = useQueryClient();
|
|
const router = useRouter();
|
|
const { t } = useTranslations();
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
|
},
|
|
onSuccess: () => {
|
|
clearCachesForSessionEnd(queryClient);
|
|
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")),
|
|
});
|
|
}
|