Files
HealthLog/src/hooks/use-auth.ts
T
Marc-André Bombeck 65fe5c34e3 fix(dashboard): welcome a new account instead of welcoming it back
The header always said "welcome back", including to an account the
setup flow had created a few minutes earlier.

/api/auth/me now publishes the account's createdAt, and the greeting
welcomes an account in its first day ("Welcome, name.") and welcomes
back every other one, including an account whose older server does not
send the field. Until the account is known, during the SSR pass and the
hydration render, the line is the time-of-day greeting alone, so
nothing claims "back" before the client knows it is true.
2026-10-29 09:13:48 +01:00

664 lines
30 KiB
TypeScript

"use client";
import {
useQuery,
useMutation,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { showErrorToast } from "@/lib/errors/error-toast";
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 { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearLastDay } from "@/lib/day/last-day";
import { clearCoachDeleteJournal } from "@/lib/ai/coach/delete-journal";
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-parse";
/**
* 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;
/**
* When the account was created (ISO). Absent from an older server image,
* which every reader treats as an established account.
*/
createdAt?: string;
email: string | null;
role: string;
heightCm: number | null;
dateOfBirth: string | null;
gender: string | null;
timezone: string;
onboardingCompletedAt: string | null;
/**
* v1.4.15: 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 — 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 — 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 — 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;
/**
* The name the app addresses the account by (dashboard greeting, reports),
* null when none was given. Edited on the profile card. Optional in the
* type so a stale /me payload coerces to "no display name".
*/
displayName?: string | null;
/**
* 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.41 — the Coach's thinking depth, resolved on the server: the level a
* turn runs at, the person's own pick, the highest option the operator
* allows, and why. Read it through `useCoachReasoning`; `null` against a
* server image that does not publish it.
*/
coachReasoning?: CoachReasoningState | 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 — 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 — 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,
// v1.41 — an older server image publishes no reasoning block; null, which
// the hook reads as "not available here".
coachReasoning:
data.coachReasoning && typeof data.coachReasoning === "object"
? data.coachReasoning
: 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 — 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();
// The day the layer last showed is a date of this account's record.
clearLastDay();
// Coach deletes this session could not confirm belong to its account; the
// next one must not re-send them against its own record.
clearCoachDeleteJournal();
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: (failure) =>
showErrorToast(t("common.networkError"), { cause: failure }),
});
}