From 78176323854cd2076854a57c97b1f069a7641b44 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 3 Aug 2026 12:11:42 +0200
Subject: [PATCH] Hide the controls a delegate cannot use, surface by surface
Every page a switch can reach now renders the controls the grant covers and
none of the others. Readings, lab results and illness entries keep their add
path at WRITE and lose edit, delete and selection at both levels. Mood,
screeners, cycle days, checkup reminders and the document vault lose every
mutation control at both levels, because no grant admits those verbs and the
routes behind them refuse a switched session outright.
Absent, not disabled, throughout. The banner already says whose record this
is, so no chip repeats it beside each missing button; the one exception is
the measurements empty state, where somebody who may add will look for the
edit control beside the row they just entered, and one muted line says where
the limit is.
Two dead ends went with them: the custom-metric section on the measurements
page linked every row into a route the shell turns a delegate away from, and
the wrench glyphs point at settings pages sharing does not cover.
---
src/app/labs/page.tsx | 40 ++--
src/app/measurements/page.tsx | 27 ++-
src/app/mood/page.tsx | 21 ++-
.../custom-metrics/custom-metric-list.tsx | 11 ++
src/components/cycle/cycle-calendar.tsx | 77 +++++---
src/components/cycle/cycle-view.tsx | 68 ++++---
src/components/documents/document-card.tsx | 43 +++--
.../documents/document-detail-sheet.tsx | 145 +++++++++------
.../documents/document-summary-block.tsx | 9 +-
.../documents/document-timeline.tsx | 3 +-
src/components/documents/documents-view.tsx | 47 +++--
.../documents/use-page-file-drop.ts | 16 +-
.../illness/illness-day-timeline.tsx | 19 +-
.../illness/illness-episode-detail.tsx | 46 +++--
src/components/illness/illness-view.tsx | 138 ++++++++------
src/components/labs/lab-biomarker-detail.tsx | 62 ++++---
src/components/labs/lab-history-list.tsx | 26 +--
src/components/labs/lab-list.tsx | 4 +-
.../vorsorge-section.tsx | 35 ++--
.../measurements/measurement-list.tsx | 173 +++++++++++-------
.../mental-health/instrument-card.tsx | 17 +-
.../mental-health/instrument-detail.tsx | 21 ++-
src/components/mood/mood-list.tsx | 102 ++++++-----
23 files changed, 703 insertions(+), 447 deletions(-)
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 737a20f75..78adf9925 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -30,12 +30,18 @@ import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicato
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export default function LabsPage() {
const { user, isAuthenticated, isLoading } = useAuth();
+ // v1.36.x — entering a lab result by hand is an admitted delegated write.
+ // 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, canManage } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -94,27 +100,29 @@ export default function LabsPage() {
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
medication page header's wrench glyph + slot + 44px tap floor. */}
-
+
+
+
+
+ )}
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
available: scan a document (OCR) or add a value by hand. The scan
option only appears when the capability probe reports a usable
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
- {ocrCapability.data?.available ? (
+ {ocrCapability.data?.available && canManage ? (
- ) : (
+ ) : canAdd ? (
- )}
+ ) : null}
>
}
/>
diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx
index 599915e8b..f6517480a 100644
--- a/src/app/measurements/page.tsx
+++ b/src/app/measurements/page.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import {
MeasurementForm,
@@ -35,6 +36,7 @@ export function resolveMeasurementReturnTo(
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
+ const { canAdd } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -149,17 +151,22 @@ export default function MeasurementsPage() {
}
description={t("measurements.subtitle")}
+ // v1.36.x — entering a reading is one of the verbs a WRITE grant
+ // 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 ? (
+
+ ) : null
}
/>
diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx
index a98078fd6..cc9b87b29 100644
--- a/src/app/mood/page.tsx
+++ b/src/app/mood/page.tsx
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { MoodForm } from "@/components/mood/mood-form";
@@ -20,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPage() {
const { user, isAuthenticated, isLoading } = useAuth();
+ const { canManage } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -95,13 +97,18 @@ export default function MoodPage() {
-
+ {/* v1.36.x — a mood entry is not one of the verbs a grant
+ admits, so it stays with the account whose record it is. A
+ delegate at any level gets no add path here. */}
+ {canManage && (
+
+ )}
>
}
/>
diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx
index b7669cc0c..01e3bafc0 100644
--- a/src/components/custom-metrics/custom-metric-list.tsx
+++ b/src/components/custom-metrics/custom-metric-list.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -29,6 +30,14 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
*/
export function CustomMetricList() {
const { t } = useTranslations();
+ // v1.36.x — the section is dropped whole inside somebody else's record.
+ // Defining a metric is not a delegated verb, and every row here links to
+ // `/custom-metrics/{id}`, which sharing does not cover: the shell turns a
+ // delegate away at that route, so the rows would be doors into a wall.
+ // Logging a value against a custom metric IS admitted, but there is no
+ // reachable surface for it under a switch, and inventing one is a read-model
+ // change this does not make.
+ const { canManage } = useRecordCapabilities();
const router = useRouter();
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState(null);
@@ -47,6 +56,8 @@ export function CustomMetricList() {
router.push(`/custom-metrics/${saved.id}`);
}
+ if (!canManage) return null;
+
return (
void;
+ /**
+ * Open the day. Omitted inside somebody else's record: no grant level
+ * admits a cycle write, so the cells stay readable and stop being controls
+ * rather than opening a sheet whose every button the server would refuse.
+ */
+ onSelectDay?: (date: string) => void;
className?: string;
}
@@ -208,34 +213,8 @@ export function CycleCalendar({
? "UNGRADED"
: undefined;
- return (
-
- remove.mutate(doc.id)}
- disabled={remove.isPending}
- >
-
- {t("documents.detail.delete")}
-
+ {/* v1.36.x — deleting, sharing and re-filing a document are the
+ owner's alone. Inside somebody else's record the sheet reads
+ the document and offers the download, and the leading slot
+ simply has nothing in it. */}
+ {canManage ? (
+ remove.mutate(doc.id)}
+ disabled={remove.isPending}
+ >
+
+ {t("documents.detail.delete")}
+
+ ) : (
+
+ )}
) : (
{t("documents.detail.kindLabel")}
+ {/* Read-only rather than absent: the value IS the content
+ a delegate came to read, and a form field that shows it
+ without accepting a change is the honest rendering. */}
) : (
diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx
index 79e4a8a17..98c444d23 100644
--- a/src/components/illness/illness-episode-detail.tsx
+++ b/src/components/illness/illness-episode-detail.tsx
@@ -8,6 +8,7 @@
* red-flag / pre-onset / nadir). Calm + retrospective — no prediction UI, no
* alarming colour.
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
@@ -36,6 +37,9 @@ function todayLocal(): string {
export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
+ // v1.36.x — logging a day, editing and resolving an episode are the
+ // owner's; a delegate reads the episode.
+ const { canManage } = useRecordCapabilities();
const fmt = useFormatters();
const {
data: episode,
@@ -93,26 +97,28 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
+ )}
);
@@ -308,6 +316,10 @@ function EpisodeGroup({
export function IllnessView() {
const { t } = useTranslations();
+ // 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, canManage } = useRecordCapabilities();
const {
data: episodes,
isLoading,
@@ -380,29 +392,33 @@ export function IllnessView() {
<>
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
-
-
-
-
-
+
+
+
+
+ )}
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
- setNewOpen(true)}
- className="min-h-11 sm:min-h-9"
- >
-
- {t("common.add")}
-
+ {canAdd && (
+ setNewOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+ {t("common.add")}
+
+ )}
>
}
/>
@@ -458,10 +474,12 @@ export function IllnessView() {
description={t("illness.empty.body")}
ctaSize="lg"
action={
- setNewOpen(true)}>
-
- {t("illness.newEpisode")}
-
+ canAdd ? (
+ setNewOpen(true)}>
+
+ {t("illness.newEpisode")}
+
+ ) : undefined
}
/>
)}
diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx
index 6985618a7..d57b42bd7 100644
--- a/src/components/labs/lab-biomarker-detail.tsx
+++ b/src/components/labs/lab-biomarker-detail.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -93,6 +94,7 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
+ const { canAdd, canManage } = useRecordCapabilities();
const { t, locale } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
@@ -346,17 +348,19 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
- setEditOpen(true)}
- disabled={!marker}
- aria-label={t("labs.biomarker.edit")}
- title={t("labs.biomarker.edit")}
- >
-
-
+ {canManage && (
+ setEditOpen(true)}
+ disabled={!marker}
+ aria-label={t("labs.biomarker.edit")}
+ title={t("labs.biomarker.edit")}
+ >
+
+
+ )}
{/* "Show all readings" mirrors the metric sub-pages' ``
control. The full reading feed lives on
`/labs/[biomarkerId]/values`; the detail page keeps the
@@ -378,18 +382,22 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null}
- setAddOpen(true)}
- // v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
- // text "Add" button crowd the row and truncate the title hard.
- // Drop the Add button to icon-only under `sm` (label kept for
- // screen readers via `aria-label`); the full text returns at `sm+`.
- className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
- aria-label={t("labs.addResult")}
- >
-
- {t("labs.addResult")}
-
+ {/* v1.36.x — entering a result is an admitted delegated write;
+ renaming or removing the marker is not. */}
+ {canAdd && (
+ setAddOpen(true)}
+ // v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
+ // text "Add" button crowd the row and truncate the title hard.
+ // Drop the Add button to icon-only under `sm` (label kept for
+ // screen readers via `aria-label`); the full text returns at `sm+`.
+ className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
+ aria-label={t("labs.addResult")}
+ >
+
+ {t("labs.addResult")}
+
+ )}
@@ -429,9 +437,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
- setAddOpen(true)}>
- {t("labs.addResult")}
-
+ canAdd ? (
+ setAddOpen(true)}>
+ {t("labs.addResult")}
+
+ ) : undefined
}
/>
diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx
index 5b547dd7a..329ce1561 100644
--- a/src/components/labs/lab-history-list.tsx
+++ b/src/components/labs/lab-history-list.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
@@ -74,6 +75,7 @@ function parseDecimal(raw: string): number | null {
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
+ const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
// Client-side column sort over the in-memory reading feed.
@@ -361,17 +363,19 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
- void openEdit(r)}
- aria-label={t("labs.editReading")}
- >
-
-
+ {canManage && (
+ void openEdit(r)}
+ aria-label={t("labs.editReading")}
+ >
+
+
+ )}
deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx
index b96f62136..99573ec0b 100644
--- a/src/components/labs/lab-list.tsx
+++ b/src/components/labs/lab-list.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
@@ -77,6 +78,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
+ const { canAdd } = useRecordCapabilities();
const { prefs } = useModuleListPrefs("labs");
// v1.22 — a short, factual line under each marker heading describing what the
@@ -167,7 +169,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
- onAddFirst ? (
+ onAddFirst && canAdd ? (
{t("labs.addFirst")}
) : undefined
}
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index 941f66f29..3f0fa6413 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -28,6 +28,7 @@
* hosting the per-reminder enable/disable toggles (moved off the
* card so the card carries a single med-style kebab).
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -219,6 +220,7 @@ export function VorsorgeSection({
variant?: "settings" | "page";
}) {
const { t } = useTranslations();
+ const { canManage } = useRecordCapabilities();
const router = useRouter();
const {
data: reminders,
@@ -359,7 +361,11 @@ export function VorsorgeSection({
// v1.18.6 (MOD-02) — the primary add affordance reads "hinzufügen" like
// every other module's add button.
- const addButton = (
+ // v1.36.x — a checkup reminder belongs to the person whose schedule it is,
+ // and no grant level admits creating, editing or satisfying one. Inside
+ // somebody else's record the schedule stays readable and every control that
+ // would change it is absent.
+ const addButton = canManage ? (
{t("common.add")}
- );
+ ) : null;
// v1.18.6 (MOD-01) — the wrench links to the Vorsorge settings page (view,
// reorder, per-reminder enable toggles), left of the Add button — the
// canonical medication-page pattern. The old kebab "Anpassen" sheet is
// gone (MOD-07); its toggles moved to the settings page.
- const wrenchButton = (
+ const wrenchButton = canManage ? (
- );
+ ) : null;
return (
@@ -709,9 +715,11 @@ export function VorsorgeSection({
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
action={
-
- {t("common.add")}
-
+ canManage ? (
+
+ {t("common.add")}
+
+ ) : undefined
}
/>
)}
@@ -787,6 +795,7 @@ function VorsorgeCard({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
// The next-due phrase reads off the SAME zone as the last-done date below
@@ -855,7 +864,7 @@ function VorsorgeCard({
// Single med-style kebab: Edit + Delete. The Delete item is the shared
// confirm-on-delete control rendered as a full-width menu row.
- const headerActions = (
+ const headerActions = canManage ? (
- );
+ ) : null;
// The measure / mark-done action. One constant appearance in every state —
// no due-driven tint, no label swap (v1.27.5 retired the former green
// "do it now" wash; a button that changes colour by schedule state reads as
// a different control). Due-ness is communicated by the discreet coloured
// next-due text above.
- const primaryButton = (
+ const primaryButton = canManage ? (
)}
- );
+ ) : null;
const deleteDialog = (
@@ -1114,7 +1123,9 @@ function VorsorgeCard({
)}
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
-
{primaryButton}
+ {primaryButton ? (
+
{primaryButton}
+ ) : null}
diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx
index 3af1468de..912e9c5d7 100644
--- a/src/components/measurements/measurement-list.tsx
+++ b/src/components/measurements/measurement-list.tsx
@@ -1,8 +1,10 @@
"use client";
+import { accountLabel } from "@/lib/sharing/account-access-view";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -283,7 +285,20 @@ export function MeasurementList({
}: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
- const { isAuthenticated } = useAuth();
+ const { isAuthenticated, user } = useAuth();
+ // 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 } =
+ useRecordCapabilities();
+ 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
+ // was offered no add button to be surprised by, there is nothing to explain.
+ const delegateName =
+ inSharedRecord && canWrite && activeRecord
+ ? accountLabel(activeRecord)
+ : null;
const unitDisplay = useUnitDisplay();
// v1.32.26 — a single-reading row renders its value + unit in the user's
// preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the
@@ -928,38 +943,58 @@ export function MeasurementList({
// an outage never reads as "you have no measurements".
void refetch()} />
) : !data?.measurements?.length ? (
- // v1.4.15 phase-C5: replaces the bare-text empty rectangle.
- // Filter-aware copy distinguishes brand-new accounts ("no
- // measurements yet") from filtered-out lists ("no measurements
- // match this filter") and exposes the right CTA for each
- // case.
- }
- title={
- typeFilter === "ALL" || lockedType
- ? t("measurements.emptyTitle")
- : t("measurements.emptyFilteredTitle")
- }
- description={
- typeFilter === "ALL" || lockedType
- ? t("measurements.emptyDescription")
- : t("measurements.emptyFilteredDescription")
- }
- action={
- // v1.8.5 — a locked-type list has no "reset filter" path;
- // the metric is fixed by the route.
- typeFilter !== "ALL" && !lockedType ? (
- setTypeFilter("ALL")}>
- {t("measurements.emptyResetFilter")}
-
- ) : onAddFirst ? (
-
-
- {t("measurements.emptyAddFirst")}
-
- ) : undefined
- }
- />
+ <>
+ {/* v1.4.15 phase-C5: replaces the bare-text empty rectangle.
+ Filter-aware copy distinguishes brand-new accounts ("no
+ measurements yet") from filtered-out lists ("no measurements
+ match this filter") and exposes the right CTA for each case. */}
+ }
+ title={
+ typeFilter === "ALL" || lockedType
+ ? t("measurements.emptyTitle")
+ : t("measurements.emptyFilteredTitle")
+ }
+ description={
+ typeFilter === "ALL" || lockedType
+ ? t("measurements.emptyDescription")
+ : t("measurements.emptyFilteredDescription")
+ }
+ action={
+ // v1.8.5 — a locked-type list has no "reset filter" path;
+ // the metric is fixed by the route.
+ typeFilter !== "ALL" && !lockedType ? (
+ setTypeFilter("ALL")}
+ >
+ {t("measurements.emptyResetFilter")}
+
+ ) : onAddFirst && canAdd ? (
+
+
+ {t("measurements.emptyAddFirst")}
+
+ ) : undefined
+ }
+ />
+ {/* The one place in the app the asymmetry is spelled out. A
+ delegate who may add will look for the edit control beside the
+ row they just entered, so the list that carries the add button
+ says once, in one muted line, where the limit is. Every other
+ hidden control stays silent: the banner already names the mode,
+ and a chip beside each absent button would be noise. */}
+ {delegateName && (
+
+ )}
+ >
) : (
<>
{/* Desktop table — rendered only when not mobile (v1.28.42 H3). */}
@@ -978,19 +1013,23 @@ export function MeasurementList({
- {/* v1.15.13 — select-all-on-page header checkbox. */}
-
+ {/* v1.15.13 — select-all-on-page header checkbox.
+ v1.36.x — bulk delete is the owner's alone, so a
+ delegate gets no selection column. */}
+ {canManage && (
+
+ )}
{t("measurements.type")}
@@ -1051,7 +1090,7 @@ export function MeasurementList({
{/* Grouped/synthetic rows aren't individually
deletable, so they have no checkbox. */}
- {!isGrouped && (
+ {!isGrouped && canManage && (
onToggleRow(m.id)}
@@ -1178,15 +1217,17 @@ export function MeasurementList({
) : (
<>
- startEdit(m)}
- aria-label={t("common.edit")}
- >
-
-
+ {canManage && (
+ startEdit(m)}
+ aria-label={t("common.edit")}
+ >
+
+
+ )}
deleteMutation.mutate(m.id)
@@ -1247,7 +1288,7 @@ export function MeasurementList({
{/* v1.15.13 — multi-select checkbox in a 44px tap
target; absent for synthetic grouped rows. */}
- {!isGrouped && (
+ {!isGrouped && canManage && (
// v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
// (itself a ``) inside a
// 44px wrapper `` for WCAG 2.5.5 — but a
@@ -1375,14 +1416,16 @@ export function MeasurementList({
) : (
<>
- startEdit(m)}
- aria-label={t("common.edit")}
- >
-
-
+ {canManage && (
+ startEdit(m)}
+ aria-label={t("common.edit")}
+ >
+
+
+ )}
deleteMutation.mutate(m.id)}
diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx
index 82642056b..4c3850b3a 100644
--- a/src/components/mental-health/instrument-card.tsx
+++ b/src/components/mental-health/instrument-card.tsx
@@ -39,6 +39,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import {
useFormatters,
@@ -71,6 +72,10 @@ export function InstrumentCard({
}) {
const { t, locale } = useTranslations();
const { date: formatDate } = useFormatters();
+ // v1.36.x — a screener is answered by the person it is about. No grant
+ // level admits it, so inside somebody else's record the card reads as
+ // history and carries no way to start one.
+ const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -173,11 +178,13 @@ export function InstrumentCard({
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
Nothing sits below it now: the attribution moved to the header's
info control, so the Start button stands alone with clean space. */}
-
-
- {t("mentalHealth.start")}
-
-
+ {canManage && (
+
+
+ {t("mentalHealth.start")}
+
+
+ )}
);
diff --git a/src/components/mental-health/instrument-detail.tsx b/src/components/mental-health/instrument-detail.tsx
index 385f2b40f..077309011 100644
--- a/src/components/mental-health/instrument-detail.tsx
+++ b/src/components/mental-health/instrument-detail.tsx
@@ -10,6 +10,7 @@
* landing stays a quiet card grid, so the trend is opt-in behind a
* deliberate click, never pushed at the moment someone arrives to check in.
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Button } from "@/components/ui/button";
import {
useFormatters,
@@ -35,6 +36,8 @@ export function InstrumentDetail({
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
+ // v1.36.x — see the card: starting a screener is not a delegated verb.
+ const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -73,14 +76,16 @@ export function InstrumentDetail({
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
-
- {t("mentalHealth.start")}
-
+ {canManage && (
+
+ {t("mentalHealth.start")}
+
+ )}
diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx
index 554b15cf3..7cd920d1c 100644
--- a/src/components/mood/mood-list.tsx
+++ b/src/components/mood/mood-list.tsx
@@ -2,6 +2,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
@@ -139,6 +140,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
+ // v1.36.x — mood is not a delegated verb. Inside somebody else's record the
+ // list stays readable and every control that would change it is absent.
+ const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
const [moodFilter, setMoodFilterRaw] = useState("ALL");
// v1.15.13 — management-list source filter + optional date range.
@@ -618,7 +622,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
>
{t("mood.emptyResetFilter")}
- ) : onAddFirst ? (
+ ) : onAddFirst && canManage ? (
{t("mood.emptyAddFirst")}
@@ -644,18 +648,20 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{/* v1.15.13 — select-all-on-page header checkbox. */}
-
+ {canManage && (
+
+ )}
- onToggleRow(entry.id)}
- aria-label={t("dataList.selectRow")}
- />
+ {canManage && (
+ onToggleRow(entry.id)}
+ aria-label={t("dataList.selectRow")}
+ />
+ )}
{entry.score}{" "}
@@ -737,15 +745,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
- startEdit(entry)}
- aria-label={t("common.edit")}
- >
-
-
+ {canManage && (
+ startEdit(entry)}
+ aria-label={t("common.edit")}
+ >
+
+
+ )}
deleteMutation.mutate(entry.id)}
title={t("mood.deleteConfirmTitle")}
@@ -789,14 +799,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
stays the single control and owns the 44px hit area
via an `after` hit-slop (clicks on a pseudo-element
hit-test against its host button). */}
-