fix(sharing): show a guardian the controls the routes accept (#939)

useRecordCapabilities answers per section now. canWriteDomain and
canManageDomain read the two lists the account payload publishes; in
one's own record they are always true, and under a refused, pending or
unproven context always false. canManage becomes the coarse switch,
true when any section is manageable, and is what the banner and the
navigation read.

Every add, edit and delete control names its own section: labs, mind,
profile, illness, cycle, medications, measurements, documents. Creates
that a WRITE route answers (visits, practitioners) ask canWriteDomain.
The generic delete button, the selection bar, the capture picker, the
dashboard quick entry and the bottom nav take the section from their
callers. Controls whose routes resolve the caller rather than the
record (settings shortcuts, chart overlay preferences, custom metrics,
the Today rail, OCR, efficacy targets, credential tabs) ask for the
caller's own record instead, and the customise-shortcut guard now
requires that shape.

The vault has no delegated write route, so its controls stay withheld
under every grant, and the documents page says so in one sentence
where the upload control would be, in all seven locales.
This commit is contained in:
Marc-André Bombeck committed 2026-09-09 19:42:27 +02:00
1 parent b5c392636f
commit e95612e3d3
64 files changed
+690 -289

No files matched your search

+15 -7
View File
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import type { ShareDomain } from "@/lib/sharing/scope";
/**
* Shared single-row delete button + confirm dialog for the data-
@@ -30,11 +31,14 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
* v1.36.x — this is the one place a row delete is spelled across the app
* (measurements, lab results, biomarkers, allergies, family history, custom
* metrics, medication inventory), so the delegation rule is enforced here
* rather than at twelve call sites: deleting is the owner's alone, at every
* grant level, so inside somebody else's record the trigger is absent. Not
* disabled — a greyed bin still claims the row is the delegate's to remove.
* A caller that ever needs a delete a delegate MAY perform would have to say
* so explicitly, and today there is none.
* rather than at twelve call sites. Not disabled when withheld — a greyed bin
* still claims the row is the delegate's to remove.
*
* v1.38.12 — deleting is a MANAGE verb, and whether a MANAGE grant reaches it
* depends on the section the row belongs to (`domain-write-support.ts`), so
* the caller names the section and this component asks `canManageDomain` for
* it. `null` says the row's delete route resolves the caller rather than the
* record (custom metrics), which no grant reaches at any level.
*/
export function DeleteButton({
onConfirm,
@@ -45,7 +49,10 @@ export function DeleteButton({
triggerTitle,
className = "",
iconClassName = "h-3.5 w-3.5",
domain,
}: {
/** The section the delete route answers under, or `null` for owner-only. */
domain: ShareDomain | null;
onConfirm: () => void;
title: string;
description: string;
@@ -57,8 +64,9 @@ export function DeleteButton({
iconClassName?: string;
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
if (!canManage) return null;
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const allowed = domain === null ? !inSharedRecord : canManageDomain(domain);
if (!allowed) return null;
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import type { ShareDomain } from "@/lib/sharing/scope";
/**
* Selection action bar for the data-management lists (measurements +
@@ -31,10 +32,13 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
* The confirm copy is passed in because the title/body differ per surface
* (measurements vs mood) and the count is interpolated by the caller.
*
* v1.36.x — bulk delete is the owner's alone at every grant level, so the bar
* is absent inside somebody else's record. The row checkboxes that feed it are
* dropped at their lists for the same reason; this guard is the backstop, so a
* list that forgets cannot leave a delete button floating over the page.
* v1.36.x — bulk delete is a MANAGE verb, so the bar is absent inside somebody
* else's record below that level. The row checkboxes that feed it are dropped
* at their lists for the same reason; this guard is the backstop, so a list
* that forgets cannot leave a delete button floating over the page.
*
* v1.38.12 — the caller names the section its bulk route answers under, and
* the bar asks `canManageDomain` for it; `null` marks an owner-only route.
*/
export function SelectionActionBar({
count,
@@ -43,7 +47,10 @@ export function SelectionActionBar({
isDeleting,
confirmTitle,
confirmBody,
domain,
}: {
/** The section the bulk-delete route answers under, or `null` for owner-only. */
domain: ShareDomain | null;
count: number;
onClear: () => void;
onConfirmDelete: () => void;
@@ -52,9 +59,10 @@ export function SelectionActionBar({
confirmBody: string;
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const allowed = domain === null ? !inSharedRecord : canManageDomain(domain);
if (count <= 0 || !canManage) return null;
if (count <= 0 || !allowed) return null;
return (
<div