mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(sharing): the switch, the banner, and the nav inside another record
Switching is three acts in a fixed order, and the order is the safety argument. Ask the server, so a refused switch leaves the browser where it was. Empty the caches a reload cannot reach, awaited rather than fired, because a wipe racing the navigation is a wipe that sometimes does not happen. Then reload the document, which is what discards the in-memory cache, the rendered React trees, and every request already in flight against the old record. The banner is the feature, not decoration. Someone who forgets they are switched will log their own blood pressure into another person's record, and there is no undo for a reading that was never theirs. So it states whose record is open, that it is read-only, and carries the way out — in the one visual register reserved for "something about this screen is not normal", at the top of the shell above every other strip. A subtle tint is what people stop seeing on the third switch. The switcher lives in one component mounted in both user menus. A way in on the laptop and no way out on the phone is how the two menus have drifted before. Nav entries that sharing does not cover drop out, and a page reached by bookmark says so instead of painting a row of refusals. Both are paint: the server refuses those routes on its own, from a frozen list this code cannot reach. Settings, connected services, notifications and the AI surfaces are the account around a record rather than the record itself. A grant can end while a browser is inside the record. Revocation clears the delegate's sessions in the same transaction; expiry does not, and leaves a session stamped with an account it may no longer act on — correct refusals under a screen that lies. One cache subscriber notices the stable errorCode and leaves, rather than asking two hundred call sites to remember.
This commit is contained in:
1 parent
80175f3bfc
commit
dd3d5646de
12 files changed
+675
-13
No files matched your search
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.36.0 — entering and leaving somebody else's record, from the browser.
|
||||
*
|
||||
* The switch is three acts in a fixed order, and the order is the whole
|
||||
* safety argument:
|
||||
*
|
||||
* 1. **Ask the server.** `POST /api/account/switch` validates the grant and
|
||||
* stamps the session. Nothing local changes until it answers, so a switch
|
||||
* the server refuses leaves the browser exactly where it was.
|
||||
* 2. **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.
|
||||
* 3. **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 } 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 { setRecordScope } from "@/lib/query-keys/record-scope";
|
||||
|
||||
/**
|
||||
* 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 the switch: stamp the cache scope, 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(accountId: string | null): Promise<never> {
|
||||
setRecordScope(accountId);
|
||||
await clearOfflineCachesForRecordSwitch();
|
||||
window.location.assign("/");
|
||||
// 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();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.accountSwitchMutation(),
|
||||
mutationFn: async (accountId: string | null) => {
|
||||
await postSwitch(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.
|
||||
queryClient.clear();
|
||||
return landOnRecord(accountId);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
isSharingAccessDenied(error)
|
||||
? t("recordSharing.switch.noLongerAvailable")
|
||||
: t("recordSharing.switch.failed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Clearing the stamp puts the browser
|
||||
* back in its own account rather than leaving somebody staring at a wall of
|
||||
* 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 reload still happens: the stamp survives, but so does the honest
|
||||
* refusal, and the alternative is a page that neither recovers nor says why.
|
||||
*/
|
||||
export function useLeaveSharedRecordOnGrantLoss() {
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
await postSwitch(null);
|
||||
} catch {
|
||||
/* the reload below is the part that matters */
|
||||
}
|
||||
await landOnRecord(null);
|
||||
}, []);
|
||||
}
|
||||
@@ -22,6 +22,19 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { TourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { setRecordScope } from "@/lib/query-keys/record-scope";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
canSwitch: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.18.6 — resume point for the module tour as the client sees it on
|
||||
@@ -162,6 +175,21 @@ export interface AuthUser {
|
||||
* payload so a missing map reads as all-available.
|
||||
*/
|
||||
moduleAvailability?: Partial<Record<ModuleKey, boolean>>;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,6 +292,13 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
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.
|
||||
const accountAccess = coerceAccountAccess(data.accountAccess);
|
||||
setRecordScope(accountAccess.active?.accountId ?? null);
|
||||
return {
|
||||
...(data as AuthUser),
|
||||
disableCoach: data.disableCoach ?? false,
|
||||
@@ -290,6 +325,28 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
data.moduleAvailability && typeof data.moduleAvailability === "object"
|
||||
? data.moduleAvailability
|
||||
: {},
|
||||
accountAccess,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the sharing block off a payload that may not carry one.
|
||||
*
|
||||
* Fails CLOSED, unlike the module maps above: an older server image (or a
|
||||
* malformed block) resolves to no accounts, no active record, and no switcher.
|
||||
* The module gates fail open because a missing map must never blank the nav;
|
||||
* this one fails closed because the affordance it drives asks the server to
|
||||
* open somebody else's health record, and an affordance offered on a server
|
||||
* that cannot honour it is worse than one that is missing.
|
||||
*/
|
||||
function coerceAccountAccess(value: unknown): AccountAccess {
|
||||
if (value === null || typeof value !== "object") return NO_ACCOUNT_ACCESS;
|
||||
const block = value as Partial<AccountAccess>;
|
||||
if (!Array.isArray(block.accounts)) return NO_ACCOUNT_ACCESS;
|
||||
return {
|
||||
accounts: block.accounts,
|
||||
active: block.active ?? null,
|
||||
canSwitch: block.canSwitch === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,6 +415,12 @@ export function useAuth() {
|
||||
*/
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user