mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Found by an adversarial pass over the release candidate before it was tagged, and it is the reason the tag waited. `POST /api/medications/intake` is admitted for delegates because a caregiver standing next to the patient has to be able to say the tablet went down. It is an update, though, and the same body can carry two other things. Snoozing writes `snoozedUntil` on the OWNER's medication row, and the reminder cron skips that medication for as long as the stamp is in the future. The field takes any future instant. So somebody with write access could stop another person's medication reminders for years, and three things made it quiet: the owner is deliberately not notified of a delegated snooze, the activity feed renders the whole family as "marked a dose", and the only accurate trace was a line in the admin audit log. The second was flipping a dose the owner had already recorded — taken to skipped and back, which rewrites their compliance history and refunds inventory. The consent screen promises that changing what is already there stays with the owner. Both refuse for a delegate now and stay open to the owner, who is the only person either was ever for. A refused attempt is filed under the owner and gets its own sentence in the activity panel rather than borrowing the one for marking a dose, because folding them together is what made the gap dangerous. Two more from the same pass: The consent notice named allergies among the things a delegate may add. That left the admitted set before this release shipped, and it said so in six languages on the one screen where somebody decides who may write into their health record. It also omitted half of what IS admitted. The copy now matches the frozen list, and the test asserts the withdrawal rather than only the presence of the rest. The banner told a WRITE delegate nothing at all — the mode line rendered only in the read case, guarded by a comment stating that write grants did not exist yet. This is the release that made them exist. It matters that the banner say it, because the rule that a refused control is absent rather than disabled rests on the banner naming the mode globally. And a third instance of the substitution this release already fixed twice: the BMI chart divided the owner's weights by the height of whoever was looking. Inside a shared record it draws nothing now rather than a number belonging to two different people.
129 lines
5.4 KiB
TypeScript
129 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import {
|
|
accountLabel,
|
|
type AccountAccessEntry,
|
|
} from "@/lib/sharing/account-access-view";
|
|
|
|
/**
|
|
* What the person at the keyboard may do to the record on screen.
|
|
*
|
|
* ## Why this exists
|
|
*
|
|
* v1.36.0 shipped account sharing read-only, and everything that consulted
|
|
* the grant lived in the application chrome: the nav bars, the banner, the
|
|
* switcher. No feature surface asked. So a delegate opened `/measurements`
|
|
* inside somebody else's record, saw the add button, filled the form, and was
|
|
* refused by the server. Nothing leaked, because the refusal was right. It
|
|
* still taught them the product was broken. This hook is what every mutation
|
|
* affordance asks before it renders.
|
|
*
|
|
* ## The two answers, and why there are two
|
|
*
|
|
* A grant at WRITE admits a short, closed list of verbs: entering readings,
|
|
* lab results, an illness entry, a biomarker, a tracked value, a side effect, a
|
|
* medication, and marking a dose taken or skipped. It admits nothing else.
|
|
* Allergies and family history were on this list and left it before v1.36.1
|
|
* shipped — the argument for them held, but the only surface that posts to
|
|
* either lives under `/settings`, which a switch closes, so the permission had
|
|
* no reachable caller. `delegable-surface-guard.test.ts` carries that argument
|
|
* in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and
|
|
* bulk-acting stay with the owner, including on an entry the delegate added
|
|
* themselves a minute ago, and so does every create the list does not name
|
|
* (a mood entry, a screener, a cycle day). Two booleans carry that:
|
|
*
|
|
* - `canAdd` — may this person add one of the admitted kinds.
|
|
* - `canManage` — may this person change what is already there, or add
|
|
* something the delegation does not cover. True only in one's own record.
|
|
*
|
|
* A surface that offers an admitted create asks `canAdd`. Everything else
|
|
* asks `canManage`. Neither is a permission decision made here: `canWrite`
|
|
* arrives resolved from the server on `accountAccess.active` and is bound,
|
|
* never recomputed (`account-access-view.ts:11-15`). What this file decides is
|
|
* only which class of affordance a resolved level covers, and that mapping is
|
|
* a property of the release, not of the caller.
|
|
*
|
|
* ## Absent, not disabled
|
|
*
|
|
* The consuming rule, stated once here because it belongs to the whole sweep:
|
|
* a control this returns `false` for does not render at all. A greyed-out
|
|
* button still says "this exists and does not work for you"; a control that is
|
|
* simply not there says "this is not part of what you were given", which is
|
|
* the truth. The banner already states the mode globally, so no per-control
|
|
* chip explains the absence — only an empty state a person would otherwise
|
|
* search a button in gets a sentence.
|
|
*/
|
|
export interface RecordCapabilities {
|
|
/** Is this browser acting on somebody else's record right now. */
|
|
inSharedRecord: boolean;
|
|
/** The grant's resolved level for the record on screen. */
|
|
canWrite: boolean;
|
|
/** May the caller add an entry of a kind the delegation admits. */
|
|
canAdd: boolean;
|
|
/** May the caller change what exists, or add what the delegation excludes. */
|
|
canManage: boolean;
|
|
}
|
|
|
|
/**
|
|
* The mapping, as a pure function of the record the session is inside.
|
|
*
|
|
* Exported so its contract can be pinned without a click: the three states a
|
|
* session can be in are enumerable, and the test enumerates them. `null` is
|
|
* one's own record, which is every capability.
|
|
*/
|
|
export function resolveRecordCapabilities(
|
|
active: AccountAccessEntry | null | undefined,
|
|
): RecordCapabilities {
|
|
if (!active) {
|
|
return {
|
|
inSharedRecord: false,
|
|
canWrite: false,
|
|
canAdd: true,
|
|
canManage: true,
|
|
};
|
|
}
|
|
return {
|
|
inSharedRecord: true,
|
|
canWrite: active.canWrite,
|
|
canAdd: active.canWrite,
|
|
canManage: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The record on screen, and what may be done to it.
|
|
*
|
|
* Reads the same resolved block the banner and the switcher read, so the
|
|
* chrome and the controls beneath it cannot disagree about what a delegate
|
|
* was given.
|
|
*
|
|
* Before `/api/auth/me` settles, `active` is absent and the answer is the
|
|
* caller's own record. That is deliberate rather than fail-closed: failing
|
|
* closed would blank every add button in the app for every account on every
|
|
* cold load, to spare a delegate one frame. The switch flow ends in a hard
|
|
* reload, so a delegate's first paint can show an add control for as long as
|
|
* the account query takes, and then it goes. A control that appears and
|
|
* withdraws is a much smaller lie than one that stays and 403s, and the
|
|
* banner lands from the same query in the same frame.
|
|
*/
|
|
export function useRecordCapabilities(): RecordCapabilities {
|
|
const { user } = useAuth();
|
|
return resolveRecordCapabilities(user?.accountAccess?.active);
|
|
}
|
|
|
|
/**
|
|
* The name of the record this browser is inside, or null in one's own.
|
|
*
|
|
* The delegate's receipt: a mutation that lands under a switch says which
|
|
* record it landed in, because "Saved" alone is the one confirmation a person
|
|
* acting for somebody else does not need. Kept beside the capabilities because
|
|
* it reads the same resolved entry and would otherwise be a fourth place that
|
|
* decides what to call somebody.
|
|
*/
|
|
export function useActiveRecordName(): string | null {
|
|
const { user } = useAuth();
|
|
const active = user?.accountAccess?.active ?? null;
|
|
return active ? accountLabel(active) : null;
|
|
}
|