mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Ask what the record on screen allows before rendering a control
Account sharing shipped read-only, and everything that consulted the grant lived in the application chrome: the nav bars, the banner, the switcher. No feature surface asked. A delegate opened somebody else's record, saw the add button on every page, filled the form, and was refused by a server that was right to refuse. Nothing leaked; it taught people the product was broken. This is the answer every mutation affordance asks for. Two booleans, because a grant at WRITE admits a short closed list of verbs and nothing else: `canAdd` for an entry of an admitted kind, `canManage` for changing what is already there or adding what the list does not name. `canWrite` arrives resolved from the server and is bound, never recomputed; what this decides is only which class of control a resolved level covers. The absent case is the permissive one. Failing closed would blank every add button in the app for every account on every cold load, to spare a delegate one frame between the reload and the account query landing.
This commit is contained in:
1 parent
4f8fa25b51
commit
3e7127dca5
2 files changed
+209
No files matched your search
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* What a delegation lets somebody do, pinned as a table.
|
||||
*
|
||||
* The hook itself is one line over `useAuth`; the decision lives in
|
||||
* `resolveRecordCapabilities`, which is pure and has exactly three inputs a
|
||||
* session can be in. So the contract is enumerated here rather than inferred
|
||||
* from a rendered page — and every affordance in the sweep binds one of these
|
||||
* two booleans, which is what makes the sweep provable at all.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - return `canManage: true` for a WRITE record → "a delegate may not change
|
||||
* what is already there" goes red.
|
||||
* - return `canAdd: true` for a READ record → "a read-only delegate adds
|
||||
* nothing" goes red.
|
||||
* - make the `!active` branch return `canAdd: false` → "the caller's own
|
||||
* record keeps everything" goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
|
||||
const READ_ONLY: AccountAccessEntry = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
access: "read",
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
const WRITABLE: AccountAccessEntry = {
|
||||
...READ_ONLY,
|
||||
access: "write",
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
describe("resolveRecordCapabilities", () => {
|
||||
it("the caller's own record keeps everything", () => {
|
||||
// `null` is not "no access", it is "this is mine". Both an unswitched
|
||||
// session and a payload from a server that has never heard of sharing
|
||||
// land here, which is why the absent case must be the permissive one.
|
||||
expect(resolveRecordCapabilities(null)).toEqual({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
});
|
||||
expect(resolveRecordCapabilities(undefined)).toEqual(
|
||||
resolveRecordCapabilities(null),
|
||||
);
|
||||
});
|
||||
|
||||
it("a read-only delegate adds nothing", () => {
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("a delegate may not change what is already there", () => {
|
||||
// The asymmetry the whole design rests on: a WRITE grant adds, and that
|
||||
// is all it does. Not even to an entry the delegate made a minute ago.
|
||||
expect(resolveRecordCapabilities(WRITABLE)).toEqual({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
canManage: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("binds the server's boolean rather than the grant's label", () => {
|
||||
// `access` is descriptive; `canWrite` is the resolved decision. A row that
|
||||
// says "write" while the server resolved false (lapsed, revoked mid-flight,
|
||||
// a level this build does not honour) must read as read-only, because two
|
||||
// programs deciding one person's access is how they end up disagreeing.
|
||||
const disagreeing: AccountAccessEntry = {
|
||||
...READ_ONLY,
|
||||
access: "write",
|
||||
canWrite: false,
|
||||
};
|
||||
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"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, allergies, family history, an illness entry, a biomarker, a
|
||||
* tracked value, a side effect, a medication, and marking a dose taken. It
|
||||
* admits nothing else. 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;
|
||||
}
|
||||
Reference in new issue
Block a user