mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
- Clear scoped reads before the server session changes - Reconcile every tab with a fresh access response
241 lines
9.4 KiB
TypeScript
241 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* v1.36.0 — entering and leaving somebody else's record, from the browser.
|
|
*
|
|
* The switch is four acts in a fixed browser-coordination order:
|
|
*
|
|
* 1. **Hold every tab.** Publish a cross-tab `blocking` transition and clear
|
|
* local reads before the POST can change the shared server session.
|
|
* 2. **Ask the server.** `POST /api/account/switch` validates the grant and
|
|
* stamps the session. Any client-visible failure stays held until a
|
|
* fresh `/api/auth/me` resolves the record the server actually kept; it
|
|
* never restores stale caches.
|
|
* 3. **Empty every cache that survives a reload.** The IndexedDB query
|
|
* snapshot and the service worker's `healthlog-data-*` cache both answer
|
|
* reads from disk. Left in place, the first paint on the other side can
|
|
* be the PREVIOUS record's numbers under a banner naming the new one —
|
|
* and nothing in the page's own code would be wrong, because the answer
|
|
* would not have come from the page. Awaited, not fired: a wipe racing
|
|
* the navigation is a wipe that sometimes does not happen.
|
|
* 4. **Reload the document.** Not `router.refresh()`, not a query
|
|
* invalidation. A hard navigation is what discards the in-memory cache,
|
|
* every React state tree holding a rendered value, and every in-flight
|
|
* request that was issued against the old record. The reload IS the cache
|
|
* flush; the two lines above it are the parts a reload cannot reach.
|
|
*
|
|
* The hook returns from step 1 and never from step 3 — the page is gone.
|
|
* Callers therefore do not need a success state, only a pending one.
|
|
*/
|
|
|
|
import {
|
|
useMutation,
|
|
useQueryClient,
|
|
type QueryClient,
|
|
} from "@tanstack/react-query";
|
|
import { useCallback } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import {
|
|
beginRecordSessionTransition,
|
|
commitRecordSessionTransition,
|
|
resolveUnknownRecordSessionTransition,
|
|
} from "@/lib/query-keys/record-session-transition";
|
|
|
|
/**
|
|
* The two stable sharing error codes, as the envelope publishes them
|
|
* (`meta.errorCode`). Named here rather than typed inline so the reset
|
|
* below and the "not available while sharing" state cannot drift apart from
|
|
* what the server sends.
|
|
*/
|
|
export const SHARING_ACCESS_DENIED = "sharing.access.denied";
|
|
export const SHARING_NOT_PERMITTED = "sharing.not_permitted";
|
|
|
|
/** The `meta.errorCode` on a failed request, when it carried one. */
|
|
export function errorCodeOf(error: unknown): string | null {
|
|
if (!(error instanceof ApiError)) return null;
|
|
const code = error.meta?.errorCode;
|
|
return typeof code === "string" ? code : null;
|
|
}
|
|
|
|
/**
|
|
* True when a request failed because the caller may no longer act as the
|
|
* account they are inside — the grant was revoked, lapsed, or never applied to
|
|
* this record.
|
|
*
|
|
* The correct response is not a retry and not an error card: it is to leave the
|
|
* record. A browser that stays inside it shows a banner claiming access it does
|
|
* not have, over a page that will 403 every read.
|
|
*/
|
|
export function isSharingAccessDenied(error: unknown): boolean {
|
|
return errorCodeOf(error) === SHARING_ACCESS_DENIED;
|
|
}
|
|
|
|
/**
|
|
* True when the surface itself is not available while acting on another
|
|
* account — grant management, credentials, integrations, notification
|
|
* channels, anything that would let a delegate extend their own reach.
|
|
*
|
|
* A different fact from the one above and worth a different message: there is
|
|
* nothing wrong with the caller's access, the page simply is not part of what
|
|
* sharing covers.
|
|
*/
|
|
export function isSharingNotPermitted(error: unknown): boolean {
|
|
return errorCodeOf(error) === SHARING_NOT_PERMITTED;
|
|
}
|
|
|
|
interface SwitchResponse {
|
|
actingAs: {
|
|
accountId: string;
|
|
username: string;
|
|
displayName: string | null;
|
|
access: "READ" | "WRITE";
|
|
} | null;
|
|
}
|
|
|
|
async function postSwitch(accountId: string | null): Promise<SwitchResponse> {
|
|
return apiPost<SwitchResponse>("/api/account/switch", { accountId });
|
|
}
|
|
|
|
/**
|
|
* Complete a server-confirmed switch: empty the disk layers, reload.
|
|
*
|
|
* Extracted from the mutation so the grant-loss reset can reuse the exact same
|
|
* sequence. Two copies of "wipe, then reload" is one copy too many for a step
|
|
* whose order is the safety property.
|
|
*/
|
|
async function landOnRecord(destination = "/"): Promise<never> {
|
|
await clearOfflineCachesForRecordSwitch();
|
|
window.location.assign(destination);
|
|
// The document is being replaced. Nothing after this runs, and the promise
|
|
// deliberately never settles so no caller paints a "done" state over a page
|
|
// that is on its way out.
|
|
return new Promise<never>(() => {});
|
|
}
|
|
|
|
/**
|
|
* Switch into a record, or (with `null`) back into the caller's own.
|
|
*
|
|
* Errors surface as a toast and leave the browser in place — including the
|
|
* `sharing.access.denied` case, which means the grant ended between the
|
|
* switcher being painted and the click. The list the switcher renders comes
|
|
* from `/api/auth/me`, so the next boot will not offer it again.
|
|
*/
|
|
export function useAccountSwitch() {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
|
|
const mutation = useMutation({
|
|
mutationKey: queryKeys.accountSwitchMutation(),
|
|
mutationFn: async ({
|
|
accountId,
|
|
destination,
|
|
}: {
|
|
accountId: string | null;
|
|
destination: string;
|
|
}) => {
|
|
// Publish the browser-wide hold before the server sees the session
|
|
// change. Every recipient blocks its shell and clears local reads before
|
|
// it reconciles the committed scope through a fresh `/me`.
|
|
const transitionId = beginRecordSessionTransition(accountId);
|
|
// The in-memory cache holds the record we are leaving. Clearing it here
|
|
// as well as reloading costs nothing and closes the window in which a
|
|
// failed navigation would leave the old entries reachable.
|
|
void queryClient.cancelQueries();
|
|
queryClient.clear();
|
|
try {
|
|
await postSwitch(accountId);
|
|
} catch (error) {
|
|
// A client error cannot prove that the server left the session alone:
|
|
// its response may have been lost after the server committed. Keep all
|
|
// tabs held until a fresh `/me` resolves the record the server kept.
|
|
resolveUnknownRecordSessionTransition(transitionId);
|
|
throw error;
|
|
}
|
|
commitRecordSessionTransition(transitionId, accountId);
|
|
return landOnRecord(destination);
|
|
},
|
|
onError: (error: unknown) => {
|
|
toast.error(
|
|
isSharingAccessDenied(error)
|
|
? t("recordSharing.switch.noLongerAvailable")
|
|
: t("recordSharing.switch.failed"),
|
|
);
|
|
},
|
|
});
|
|
|
|
return {
|
|
...mutation,
|
|
/** Preserve the established own-record and exit call sites. */
|
|
mutate: (accountId: string | null) =>
|
|
mutation.mutate({ accountId, destination: "/" }),
|
|
/** Enter a scoped record directly at a server-presentable destination. */
|
|
switchTo: (accountId: string, destination: string) =>
|
|
mutation.mutate({ accountId, destination }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Leave a record the server has stopped honouring.
|
|
*
|
|
* The switcher-reset the contract asks for: on `sharing.access.denied` the
|
|
* session is still stamped with an account it may no longer act on, and every
|
|
* delegable read from here on is a 403. When the server confirms the reset,
|
|
* its own-record scope replaces the stamp rather than leaving somebody
|
|
* staring at refusals under a banner that says they are somewhere they are
|
|
* not.
|
|
*
|
|
* `POST /api/account/switch` with `null` is an actor surface and always
|
|
* succeeds — it is the way out, by design. If even that fails (offline, server
|
|
* down), the browser keeps the transition unresolved and reloads. A fresh
|
|
* `/api/auth/me`, not a local scope guess, decides what may render next.
|
|
*/
|
|
/**
|
|
* Watch the cache for the moment the server stops honouring the switch.
|
|
*
|
|
* A plain function over a QueryClient rather than an effect body, so a test
|
|
* can drive a real cache into the error state and check what happens — which
|
|
* is the only way to tell a subscriber that fires from one that was written
|
|
* and never wired.
|
|
*
|
|
* Fires `onLoss` at most once. The caller reloads the document, and a second
|
|
* trigger would queue a second navigation behind one already in flight.
|
|
*/
|
|
export function subscribeToGrantLoss(
|
|
queryClient: QueryClient,
|
|
onLoss: () => void,
|
|
): () => void {
|
|
let fired = false;
|
|
return queryClient.getQueryCache().subscribe((event) => {
|
|
if (fired) return;
|
|
if (event.type !== "updated") return;
|
|
if (event.query.state.status !== "error") return;
|
|
if (!isSharingAccessDenied(event.query.state.error)) return;
|
|
fired = true;
|
|
onLoss();
|
|
});
|
|
}
|
|
|
|
export function useLeaveSharedRecordOnGrantLoss() {
|
|
const queryClient = useQueryClient();
|
|
return useCallback(async () => {
|
|
const transitionId = beginRecordSessionTransition(null);
|
|
void queryClient.cancelQueries();
|
|
queryClient.clear();
|
|
try {
|
|
await postSwitch(null);
|
|
commitRecordSessionTransition(transitionId, null);
|
|
} catch {
|
|
// The grant-loss path deliberately still reloads, but it cannot claim
|
|
// an own-record scope when the POST outcome is unknown. The next `/me`
|
|
// is the only authority that may release this resolving hold.
|
|
resolveUnknownRecordSessionTransition(transitionId);
|
|
}
|
|
await landOnRecord();
|
|
}, [queryClient]);
|
|
}
|