mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
refactor(sharing): retire the coarse "may this person add" answer
`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said "May this caller ADD to that record", and the client hook republished it as `canAdd`, so fourteen controls across measurements, medications, labs, illness and the dashboard gated a create on a boolean that answers `true` for a WRITE grant scoped to the document vault — a grant whose writable-section list is empty, because the vault takes no delegated write at any level. Nothing shipped was visibly wrong before the capture picker, because the nav drops a section a grant does not open. That is a property of one surface, not of the answer, and the answer is the thing many controls read. So the coarse answer is gone from the hook, and every consumer asks `canWriteDomain` for its own section instead: `measurements` on the two measurement surfaces and the dashboard's empty state, `medications` on the medication list, table, ledger, card menu and side effects, `labs` on the lab list and biomarker detail, `illness` on the episode list. Each of those is the section its own route declares. `canWrite` keeps its name and its wire shape — native clients read it — and its docblock now says what it is: whether the grant is a reading one, with the per-section lists beside it for the question a control actually has. The hook table gained the vault-scoped WRITE grant as its own leg: level true, every section false. The take-all-due fixture described a grant the server never mints — `canWrite: true` over an empty writable list — and now derives its list from the same table the server derives it from.
This commit is contained in:
1 parent
b6d3f9903f
commit
e7a1950f3a
25 files changed
+146
-88
No files matched your search
@@ -161,12 +161,11 @@ describe("what the canonical readers make of the same payload", () => {
|
||||
expect(capabilities.sections).toEqual(fixture.sections);
|
||||
expect(capabilities.recordKind).toBe(fixture.recordKind);
|
||||
expect(capabilities.inSharedRecord).toBe(true);
|
||||
// Resolved server-side and rendered, never re-derived: `canAdd` follows
|
||||
// `canWrite`, and `canManage` follows the published `manageableDomains`
|
||||
// list rather than the level — a delegate's management reach is the
|
||||
// server's answer, section by section.
|
||||
// Resolved server-side and rendered, never re-derived: `canWrite` is the
|
||||
// published level, and both per-section answers follow the published
|
||||
// lists rather than the level — a delegate's reach is the server's
|
||||
// answer, section by section.
|
||||
expect(capabilities.canWrite).toBe(fixture.canWrite);
|
||||
expect(capabilities.canAdd).toBe(fixture.canWrite);
|
||||
const manageable = active?.manageableDomains ?? [];
|
||||
expect(capabilities.canManage).toBe(manageable.length > 0);
|
||||
expect(manageable.length > 0).toBe(fixture.level === "manage");
|
||||
|
||||
@@ -44,7 +44,8 @@ export default function LabsPage() {
|
||||
// Scanning a report is not: it files a document and commits through the OCR
|
||||
// route, and neither is delegated. So a WRITE delegate keeps the manual add
|
||||
// and loses the choice menu around it.
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canAddLab = canWriteDomain("labs");
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -152,7 +153,7 @@ export default function LabsPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : canAdd ? (
|
||||
) : canAddLab ? (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
|
||||
@@ -35,7 +35,8 @@ export function resolveMeasurementReturnTo(
|
||||
|
||||
export default function MeasurementsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddMeasurement = canWriteDomain("measurements");
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -148,7 +149,7 @@ export default function MeasurementsPage() {
|
||||
// admits, so this is the header action a delegate keeps. A read-only
|
||||
// delegate loses it entirely rather than meeting a disabled one.
|
||||
actions={
|
||||
canAdd ? (
|
||||
canAddMeasurement ? (
|
||||
<Button
|
||||
data-slot="measurement-add"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -168,12 +169,12 @@ export default function MeasurementsPage() {
|
||||
answers. `?add=<TYPE>` opens it without passing the button, and a
|
||||
deep link is the same affordance as the control that produces it, so
|
||||
it gets the same gate. Gating the open rather than only the param
|
||||
also covers the first-paint window: `canAdd` reads true until
|
||||
also covers the first-paint window: `canAddMeasurement` reads true until
|
||||
`/api/auth/me` settles, and a sheet opened in that frame withdraws
|
||||
when the answer lands instead of standing on a form the server
|
||||
refuses. */}
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen && canAdd}
|
||||
open={dialogOpen && canAddMeasurement}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/components/medications/log-intake-dialog", () => ({
|
||||
import MedicationsPage from "@/app/medications/page-client";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
@@ -75,7 +76,17 @@ const READ_ONLY: AccountAccess = {
|
||||
};
|
||||
const WRITABLE: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: { ...OWNER, access: "write", level: "write", canWrite: true },
|
||||
active: {
|
||||
...OWNER,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
// Published the way the server publishes it: the sections a WRITE grant
|
||||
// over the whole record can add to. A fixture that said `canWrite: true`
|
||||
// over an empty list described a grant the server never mints, and the
|
||||
// control asks the list.
|
||||
writableDomains: delegatedDomains("write", null, "write"),
|
||||
},
|
||||
canSwitch: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -169,7 +169,8 @@ export default function MedicationsPageClient() {
|
||||
// to tapping each card in turn — so withholding it made a caregiver with
|
||||
// five morning tablets tap five times for nothing. The customize page stays
|
||||
// withheld; sharing does not cover it at all.
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canAddMedication = canWriteDomain("medications");
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
@@ -417,7 +418,7 @@ export default function MedicationsPageClient() {
|
||||
card's own one-tap job). Calm outline variant so the primary
|
||||
Add button keeps the visual lead; same responsive 44-px
|
||||
mobile tap floor as its neighbours. */}
|
||||
{canAdd && dueMeds.length >= 2 && (
|
||||
{canAddMedication && dueMeds.length >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTakeAllOpen(true)}
|
||||
@@ -455,7 +456,7 @@ export default function MedicationsPageClient() {
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
{canAdd && (
|
||||
{canAddMedication && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
@@ -516,7 +517,7 @@ export default function MedicationsPageClient() {
|
||||
title={t("medications.emptyTitle")}
|
||||
description={t("medications.emptyDescription")}
|
||||
action={
|
||||
canAdd ? (
|
||||
canAddMedication ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.firstMedication")}
|
||||
@@ -614,9 +615,9 @@ export default function MedicationsPageClient() {
|
||||
its own ResponsiveSheet shell with the sticky footer. */}
|
||||
{/* v1.36.x — `?new=1` (the retired `/medications/new` route redirects
|
||||
here) opens the create wizard without passing the gated Add control,
|
||||
so the wizard asks the same `canAdd` the control asks. */}
|
||||
so the wizard asks the same `canAddMedication` the control asks. */}
|
||||
<MedicationWizardDialog
|
||||
open={dialogOpen && canAdd}
|
||||
open={dialogOpen && canAddMedication}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode="create"
|
||||
onSuccess={closeDialog}
|
||||
@@ -632,10 +633,10 @@ export default function MedicationsPageClient() {
|
||||
attribution + inventory consumption identical to N individual
|
||||
taps — see take-all-due.ts); failed medications stay due. */}
|
||||
{/* Gated on the same answer the button is, for the first-paint reason
|
||||
`/measurements` documents: `canAdd` reads true until `/api/auth/me`
|
||||
`/measurements` documents: `canAddMedication` reads true until `/api/auth/me`
|
||||
settles, and a dialog opened in that frame withdraws when the answer
|
||||
lands rather than standing on a confirm the server would refuse. */}
|
||||
{takeAllOpen && canAdd && (
|
||||
{takeAllOpen && canAddMedication && (
|
||||
<TakeAllDueDialog
|
||||
open={takeAllOpen}
|
||||
onOpenChange={setTakeAllOpen}
|
||||
|
||||
@@ -254,7 +254,8 @@ export default function DashboardPageClient({
|
||||
value: number | null | undefined,
|
||||
): number | null =>
|
||||
value == null ? null : unitDisplay.toDisplayDelta(type, value);
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddMeasurement = canWriteDomain("measurements");
|
||||
const [quickEntryDialog, setQuickEntryDialog] =
|
||||
useState<QuickEntryDialog>(null);
|
||||
|
||||
@@ -2028,7 +2029,7 @@ export default function DashboardPageClient({
|
||||
title={t("dashboard.emptyTitle")}
|
||||
description={t("dashboard.emptyDescription")}
|
||||
action={
|
||||
canAdd ? (
|
||||
canAddMeasurement ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
* path is not proven at all. That leg lives in `e2e/account-sharing.spec.ts`.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
|
||||
* read-only legs for the intake row and the card menu go red.
|
||||
* - `useRecordCapabilities` answering `canWriteDomain` for every section →
|
||||
* the read-only legs for the intake row and the card menu go red.
|
||||
* - `DeleteButton` dropping its `canManageDomain` bail → "no row delete inside
|
||||
* somebody else's record" goes red; answering it for every section → the
|
||||
* vault leg of "a guardian's row delete follows the section" goes red.
|
||||
@@ -533,9 +533,10 @@ describe("the capture picker's kinds", () => {
|
||||
});
|
||||
|
||||
it("offers a scoped WRITE delegate only the kinds its sections cover", () => {
|
||||
// The gap this closes. `canAdd` is the grant's LEVEL with no scope term,
|
||||
// so a labs-scoped WRITE grant answered true and the picker offered a
|
||||
// weight form and a dose the server refuses at `grantCoversDomain`.
|
||||
// The gap this closes. The coarse answer was the grant's LEVEL with no
|
||||
// scope term, so a labs-scoped WRITE grant read as true and the picker
|
||||
// offered a weight form and a dose the server refuses at
|
||||
// `grantCoversDomain`.
|
||||
expect(visibleCaptureKinds(caps("write", ["labs"]), [...ALL])).toEqual([]);
|
||||
expect(
|
||||
visibleCaptureKinds(caps("write", ["measurements"]), [...ALL]),
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canAdd: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -320,7 +320,8 @@ export function IllnessView() {
|
||||
// v1.36.x — opening an illness entry is an admitted delegated write. Logging
|
||||
// a day against one, editing it, resolving it and deleting it are not, so a
|
||||
// delegate can start the record of an illness and nothing more.
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canAddEpisode = canWriteDomain("illness");
|
||||
const {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
@@ -412,7 +413,7 @@ export function IllnessView() {
|
||||
)}
|
||||
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
|
||||
other module, not the bespoke "neue Episode". */}
|
||||
{canAdd && (
|
||||
{canAddEpisode && (
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -476,7 +477,7 @@ export function IllnessView() {
|
||||
description={t("illness.empty.body")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
canAdd ? (
|
||||
canAddEpisode ? (
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
|
||||
@@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200;
|
||||
*/
|
||||
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddLab = canWriteDomain("labs");
|
||||
const canManageLabs = canManageDomain("labs");
|
||||
const { t, locale } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -378,7 +379,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
) : null}
|
||||
{/* v1.36.x — entering a result is an admitted delegated write;
|
||||
renaming or removing the marker is not. */}
|
||||
{canAdd && (
|
||||
{canAddLab && (
|
||||
<Button
|
||||
onClick={() => setAddOpen(true)}
|
||||
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
|
||||
@@ -436,7 +437,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
title={t("labs.detail.emptyTitle")}
|
||||
description={t("labs.detail.emptyDescription")}
|
||||
action={
|
||||
canAdd ? (
|
||||
canAddLab ? (
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
{t("labs.addResult")}
|
||||
</Button>
|
||||
|
||||
@@ -93,7 +93,8 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
|
||||
|
||||
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddLab = canWriteDomain("labs");
|
||||
const { prefs } = useModuleListPrefs("labs");
|
||||
|
||||
// v1.22 — a short, factual line under each marker heading describing what the
|
||||
@@ -184,7 +185,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
title={t("labs.emptyTitle")}
|
||||
description={t("labs.emptyDescription")}
|
||||
action={
|
||||
onAddFirst && canAdd ? (
|
||||
onAddFirst && canAddLab ? (
|
||||
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
|
||||
@@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
|
||||
@@ -55,11 +55,10 @@ type CaptureKind = "measurement" | "medication" | "mood";
|
||||
*
|
||||
* One question per kind, asked of that kind's own section. The picker used to
|
||||
* ask a coarse one as well — a closed list of kinds a WRITE grant admits,
|
||||
* offered whenever `canAdd` was true — and `canAdd` is bound to the grant's
|
||||
* LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
|
||||
* published `canAdd: true` and got a weight form the server refuses; one
|
||||
* scoped to `["documents"]`, which takes no delegated write at any level, got
|
||||
* both. `writableDomains` is already the level × scope × route-table
|
||||
* offered whenever the grant could write anywhere — and that answer is the
|
||||
* grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
|
||||
* read as true and got a weight form the server refuses; one scoped to
|
||||
* `["documents"]`, which takes no delegated write at any level, got both. `writableDomains` is already the level × scope × route-table
|
||||
* intersection, so asking it per section answers both cases and the mood
|
||||
* entry's MANAGE-only create as well.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canAdd: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -292,8 +292,9 @@ export function MeasurementList({
|
||||
// v1.36.x — inside somebody else's record, only the add path survives:
|
||||
// entering a reading is an admitted delegated write, editing and deleting
|
||||
// one are not. The row controls are dropped rather than disabled.
|
||||
const { canAdd, canManage, inSharedRecord, canWrite } =
|
||||
const { canWriteDomain, canManage, inSharedRecord, canWrite } =
|
||||
useRecordCapabilities();
|
||||
const canAddMeasurement = canWriteDomain("measurements");
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
// Named only when the sentence below is owed: a delegate who may add but
|
||||
// not change. In the caller's own record, and for a read-only delegate who
|
||||
@@ -1068,7 +1069,7 @@ export function MeasurementList({
|
||||
>
|
||||
{t("measurements.emptyResetFilter")}
|
||||
</Button>
|
||||
) : onAddFirst && canAdd ? (
|
||||
) : onAddFirst && canAddMeasurement ? (
|
||||
<Button onClick={onAddFirst}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurements.emptyAddFirst")}
|
||||
|
||||
@@ -36,8 +36,9 @@ export function MedicationIntakeActions({
|
||||
// v1.36.x — marking a dose taken or skipped is the verb the delegation was
|
||||
// written for. A read-only delegate sees the card and its schedule and no
|
||||
// action row; the card is otherwise unchanged.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
if (!canAdd) return null;
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canRecordIntake = canWriteDomain("medications");
|
||||
if (!canRecordIntake) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -175,7 +175,7 @@ export function LedgerAddDialog({
|
||||
// confirmation names the record, because "Taken" alone is the one
|
||||
// confirmation a person acting for somebody else does not need. The
|
||||
// ledger's "+ Eintrag" button that opens this dialog is gated on
|
||||
// `canAdd`, so a delegate reaches it.
|
||||
// `canWriteDomain("medications")`, so a delegate reaches it.
|
||||
const toastOptions = intakeToastOptions({
|
||||
recordName,
|
||||
eventId: undefined,
|
||||
|
||||
@@ -173,7 +173,8 @@ export function DoseHistoryLedger({
|
||||
// v1.36.x — a delegate may record a dose, which is what "add" and the
|
||||
// inline "mark taken" do. Editing, deleting, pinning and un-skipping an
|
||||
// existing event are the owner's, so the row menu is absent for them.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddIntake = canWriteDomain("medications");
|
||||
// The record this ledger's writes land in, when it is not the caller's own.
|
||||
// The two sibling intake paths in `use-medication-intake.ts` already drop
|
||||
// Undo and name the record; this third one builds its own toast and missed
|
||||
@@ -434,7 +435,7 @@ export function DoseHistoryLedger({
|
||||
{t("medications.detail.verlauf.summaryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{canAdd && (
|
||||
{canAddIntake && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -598,7 +599,8 @@ export function LedgerRowItem({
|
||||
onUnpin: (eventId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddIntake = canWriteDomain("medications");
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const fmt = useFormatters();
|
||||
|
||||
@@ -705,7 +707,7 @@ export function LedgerRowItem({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showTakenButton && canAdd && (
|
||||
{showTakenButton && canAddIntake && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -734,7 +736,7 @@ export function LedgerRowItem({
|
||||
items in one menu, a delegate gets skip as its own control beside
|
||||
take, which is the shape the card already uses. The owner keeps it
|
||||
where it was. */}
|
||||
{showTakenButton && canAdd && !canManageMedications && (
|
||||
{showTakenButton && canAddIntake && !canManageMedications && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -62,9 +62,10 @@ export function MedicationCardMenu({
|
||||
// medication and opening its history editor are not. When nothing in the
|
||||
// menu survives, the trigger goes with it rather than opening onto an empty
|
||||
// sheet. The card itself is untouched: no wash, no badge, no colour change.
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddSideEffect = canWriteDomain("medications");
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const showSideEffect = onLogSideEffect != null && canAdd;
|
||||
const showSideEffect = onLogSideEffect != null && canAddSideEffect;
|
||||
if (!canManageMedications && !showSideEffect) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -407,7 +407,8 @@ function MedicationTableRowItem({
|
||||
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
|
||||
// v1.36.x — marking a dose is admitted; a read-only delegate sees the row
|
||||
// without its action pair.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddIntake = canWriteDomain("medications");
|
||||
|
||||
// SAME batched compliance source as the cards.
|
||||
const { data: compliance } = useMedicationComplianceSummary(medication.id);
|
||||
@@ -685,7 +686,7 @@ function MedicationTableRowItem({
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{stockCell}</TableCell>
|
||||
<TableCell>
|
||||
{medication.active && canAdd ? (
|
||||
{medication.active && canAddIntake ? (
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
|
||||
|
||||
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddSideEffect = canWriteDomain("medications");
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -250,7 +251,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
)}
|
||||
{/* v1.36.x — noting a side effect is an admitted delegated write.
|
||||
Removing one that is already noted is not. */}
|
||||
{canAdd && (
|
||||
{canAddSideEffect && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
* 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.
|
||||
* - answer `canWriteDomain` from `level` instead of the published list →
|
||||
* "a read-only delegate adds nothing" and the scoped legs go red.
|
||||
* - make the `!active` branch answer `canWriteDomain: false` → "the caller's
|
||||
* own record keeps everything" goes red.
|
||||
* - answer `canManageDomain` from `level === "manage"` instead of the list →
|
||||
* "the vault is never manageable" goes red.
|
||||
*/
|
||||
@@ -76,7 +76,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(own).toMatchObject({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
// No grant, so no level and no narrowing — not a fabricated "manage"
|
||||
// over all eight sections, which a consumer would then have to tell
|
||||
@@ -91,10 +90,12 @@ describe("resolveRecordCapabilities", () => {
|
||||
}
|
||||
expect(resolveRecordCapabilities(undefined)).toMatchObject({
|
||||
inSharedRecord: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
recordKind: "self",
|
||||
});
|
||||
expect(resolveRecordCapabilities(undefined).canWriteDomain("labs")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a malformed published block instead of treating it as own record", () => {
|
||||
@@ -102,7 +103,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(refused).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
level: null,
|
||||
sections: [],
|
||||
@@ -120,7 +120,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(reader).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
level: "read",
|
||||
sections: null,
|
||||
@@ -139,7 +138,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(writer).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
canManage: false,
|
||||
level: "write",
|
||||
sections: null,
|
||||
@@ -155,6 +153,26 @@ describe("resolveRecordCapabilities", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a grant that can write nothing is still a WRITE grant, and offers nothing", () => {
|
||||
// The reason the coarse answer is gone. `canWrite` is the LEVEL and has no
|
||||
// scope term, so a WRITE grant scoped to the vault — which takes no
|
||||
// delegated write at any level — answers true while every section answers
|
||||
// false. A control gating on the level would paint an upload button that
|
||||
// 403s at `grantCoversDomain`.
|
||||
const vaultOnly = resolveRecordCapabilities({
|
||||
...WRITABLE,
|
||||
sections: ["documents"],
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
});
|
||||
expect(vaultOnly.canWrite).toBe(true);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(vaultOnly.canWriteDomain(domain), domain).toBe(false);
|
||||
expect(vaultOnly.canManageDomain(domain), domain).toBe(false);
|
||||
}
|
||||
expect(vaultOnly.canManage).toBe(false);
|
||||
});
|
||||
|
||||
it("carries the level and the sections through untouched", () => {
|
||||
// The two facts v1.37.0 adds are bound, not interpreted. `sections` is
|
||||
// the server's list in the server's order; `level` is the server's word.
|
||||
@@ -177,7 +195,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(manager).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
level: "manage",
|
||||
sections: null,
|
||||
@@ -219,7 +236,12 @@ describe("resolveRecordCapabilities", () => {
|
||||
access: "write",
|
||||
canWrite: false,
|
||||
};
|
||||
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(
|
||||
resolveRecordCapabilities(disagreeing).canWriteDomain(domain),
|
||||
domain,
|
||||
).toBe(false);
|
||||
}
|
||||
// The same posture for the lists: a scoped WRITE grant is writable only
|
||||
// where its list says so, not wherever its level would reach.
|
||||
const scopedWriter: AccountAccessEntry = {
|
||||
@@ -246,7 +268,7 @@ describe("resolveRecordCapabilities", () => {
|
||||
* every delegable route will refuse, while `active` being null would otherwise
|
||||
* make this hook answer "your own record, all controls". That combination
|
||||
* paints an add button on a page whose every write is about to 403 — the exact
|
||||
* failure `canAdd` exists to end.
|
||||
* failure the per-section answers exist to end.
|
||||
*/
|
||||
describe("an unprovable record context withholds every control", () => {
|
||||
it("holds when the raw selector names a record the grant no longer opens", () => {
|
||||
@@ -254,7 +276,9 @@ describe("an unprovable record context withholds every control", () => {
|
||||
recordContextIsUnproven({ epoch: 3, scope: "acct-owner" }, null),
|
||||
).toBe(true);
|
||||
const held = resolveRecordCapabilities(null, false, false, true);
|
||||
expect(held.canAdd).toBe(false);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(held.canWriteDomain(domain), domain).toBe(false);
|
||||
}
|
||||
expect(held.canManage).toBe(false);
|
||||
// REFUSED, not pending. `recordSessionPending` renders a bare spinner with
|
||||
// no controls, which is right for a switch in flight and a wedge here: this
|
||||
|
||||
@@ -43,9 +43,8 @@ const ALWAYS = () => true;
|
||||
* server's table (`domain-write-support.ts`), published per grant as two
|
||||
* lists on `accountAccess.active`. Four answers carry that:
|
||||
*
|
||||
* - `canAdd` — may this person add one of the admitted kinds.
|
||||
* - `canWriteDomain(domain)` — is there a delegated write in this section
|
||||
* the grant satisfies.
|
||||
* the grant satisfies. The only question a create affordance asks.
|
||||
* - `canManageDomain(domain)` — may this person change what is already
|
||||
* there in this section, or add what a WRITE grant does not cover.
|
||||
* - `canManage` — the coarse switch: is there ANY section this person may
|
||||
@@ -53,7 +52,11 @@ const ALWAYS = () => true;
|
||||
* because a control belongs to one section and the answer differs per
|
||||
* section (the vault is read-only under every grant).
|
||||
*
|
||||
* A surface that offers an admitted create asks `canAdd`. A control that
|
||||
* A surface that offers an admitted create asks `canWriteDomain` for its own
|
||||
* section. There is deliberately no coarse "may this person add anything":
|
||||
* `canWrite` is the grant's level with no scope term, so a WRITE grant scoped
|
||||
* to `["labs"]` answers true and a weight form built on that answer 403s at
|
||||
* `grantCoversDomain`. A control that
|
||||
* edits, deletes or creates what WRITE does not cover asks `canManageDomain`
|
||||
* for its own section. A control whose route resolves the caller rather than
|
||||
* the record (settings, credentials, AI budget, chart preferences) asks
|
||||
@@ -92,10 +95,13 @@ export interface RecordCapabilities {
|
||||
accessRefused?: boolean;
|
||||
/** Is this browser acting on somebody else's record right now. */
|
||||
inSharedRecord: boolean;
|
||||
/** The grant's resolved level for the record on screen. */
|
||||
/**
|
||||
* The grant's resolved level for the record on screen — LEVEL ONLY, with no
|
||||
* scope term. A WRITE grant scoped to the document vault answers true here
|
||||
* and can write nothing anywhere. Never gate a control on it; ask
|
||||
* {@link canWriteDomain} for the control's own section.
|
||||
*/
|
||||
canWrite: boolean;
|
||||
/** May the caller add an entry of a kind the delegation admits. */
|
||||
canAdd: boolean;
|
||||
/**
|
||||
* May the caller change what exists somewhere in this record, or add what
|
||||
* the delegation excludes. In one's own record, always. In a shared record,
|
||||
@@ -166,7 +172,6 @@ export function resolveRecordCapabilities(
|
||||
recordSessionPending: recordSessionPending || undefined,
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
canWriteDomain: NEVER,
|
||||
canManageDomain: NEVER,
|
||||
@@ -179,7 +184,6 @@ export function resolveRecordCapabilities(
|
||||
return {
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: ALWAYS,
|
||||
canManageDomain: ALWAYS,
|
||||
@@ -201,7 +205,6 @@ export function resolveRecordCapabilities(
|
||||
return {
|
||||
inSharedRecord: true,
|
||||
canWrite: active.canWrite,
|
||||
canAdd: active.canWrite,
|
||||
canManage: manageable.size > 0,
|
||||
canWriteDomain: (domain) => writable.has(domain),
|
||||
canManageDomain: (domain) => manageable.has(domain),
|
||||
@@ -243,7 +246,7 @@ export function resolveRecordCapabilities(
|
||||
* opens: every delegable route will refuse, while `active` being null makes
|
||||
* this hook answer "your own record, all controls". That combination paints an
|
||||
* add button on a page whose every write is about to 403 — the exact failure
|
||||
* `canAdd` was introduced to end. So a disagreement holds instead.
|
||||
* the per-section answers exist to end. So a disagreement holds instead.
|
||||
*
|
||||
* A null or absent `recordSession` is not a disagreement. It is the Bearer
|
||||
* transport (no session row, no switch state) or a server image that predates
|
||||
|
||||
@@ -96,11 +96,20 @@ export interface AccountAccessEntry {
|
||||
*/
|
||||
sections: ShareDomain[] | null;
|
||||
/**
|
||||
* May this caller ADD to that record. Resolved server-side. It does not by
|
||||
* itself mean edit or delete — a WRITE grant adds and does nothing more, and
|
||||
* that stayed true when a third level arrived. A MANAGE grant answers true
|
||||
* here as well, and what it additionally admits is said by `level` rather
|
||||
* than smuggled into this boolean.
|
||||
* The grant's LEVEL, resolved server-side: is it at or above WRITE.
|
||||
*
|
||||
* It is not "may this caller add something", and reading it that way is how
|
||||
* section-blind controls happen. There is no scope term in it: a WRITE grant
|
||||
* scoped to the document vault answers `true` here with
|
||||
* `writableDomains: []`, because the vault takes no delegated write at any
|
||||
* level. Ask {@link AccountAccessEntry.writableDomains} for the section the
|
||||
* control belongs to; this boolean answers only whether the grant is a
|
||||
* reading one.
|
||||
*
|
||||
* It does not by itself mean edit or delete — a WRITE grant adds and does
|
||||
* nothing more, and that stayed true when a third level arrived. A MANAGE
|
||||
* grant answers true here as well, and what it additionally admits is said
|
||||
* by `level` rather than smuggled into this boolean.
|
||||
*/
|
||||
canWrite: boolean;
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user