diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx
index 8013d1f2b..544d2a86c 100644
--- a/src/app/medications/page-client.tsx
+++ b/src/app/medications/page-client.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useCallback, useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
@@ -159,6 +160,11 @@ function MedicationCardSkeleton() {
export default function MedicationsPageClient() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
+ // v1.36.x — adding a medication and marking a dose are both admitted
+ // delegated writes, so a WRITE delegate keeps the Add menu whole. The
+ // take-all-due sweep is not (it rides the bulk intake route), and neither is
+ // the customize page, which sharing does not cover at all.
+ const { canAdd, canManage } = useRecordCapabilities();
// 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 +423,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. */}
- {dueMeds.length >= 2 && (
+ {canManage && dueMeds.length >= 2 && (
+ )}
{/* v1.14.0 — the "Add" button is now a choice: log an intake
(incl. a backdated one) against an existing medication, or
create a new medication. v1.12.2 — match the dashboard Add
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. */}
-
-
-
-
- {t("medications.addMedication")}
-
-
-
- setLogIntakeOpen(true)}
- disabled={activeMeds.length === 0}
- >
-
- {t("medications.addChoice.logIntake")}
-
-
-
- {t("medications.addChoice.newMedication")}
-
-
-
+ {canAdd && (
+
+
+
+
+ {t("medications.addMedication")}
+
+
+
+ setLogIntakeOpen(true)}
+ disabled={activeMeds.length === 0}
+ >
+
+ {t("medications.addChoice.logIntake")}
+
+
+
+ {t("medications.addChoice.newMedication")}
+
+
+
+ )}
@@ -511,10 +521,12 @@ export default function MedicationsPageClient() {
title={t("medications.emptyTitle")}
description={t("medications.emptyDescription")}
action={
-
-
- {t("medications.firstMedication")}
-
+ canAdd ? (
+
+
+ {t("medications.firstMedication")}
+
+ ) : undefined
}
/>
) : tableView ? (
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index 77138eece..868359f06 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -25,6 +25,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
@@ -37,6 +38,11 @@ export function DashboardHeader({
const { user } = useAuth();
const mounted = useMounted();
const nutrientsEnabled = useModuleEnabled("nutrients");
+ // v1.36.x — the dashboard quick-add offers four kinds; a delegation admits
+ // two of them (a reading, a dose). Mood and water stay with the account
+ // whose record it is, and the customize shortcut points at a settings page
+ // sharing does not cover at all.
+ const { canAdd, canManage } = useRecordCapabilities();
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -87,24 +93,27 @@ export function DashboardHeader({
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
touch-target contract the add button also honours, shrinking
back to the 40 px icon footprint on sm+. */}
-
-
-
-
-
-
-
- {/* v1.4.33 maintainer-item-7 — restore proportional sizing
+
+
+
+
+ )}
+ {canAdd && (
+
+
+ {/* v1.4.33 maintainer-item-7 — restore proportional sizing
across viewports. The v1.4.27 fix pinned a `size="sm"
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
contract on mobile, but `size="sm"` is h-8 (32 px) and
@@ -116,53 +125,56 @@ export function DashboardHeader({
44 px tall under finger pressure and shrinks back to
the desktop-friendly 36 px on `sm:` upwards. The icon
+ label keep the same visual contract. */}
-
+
+ {t("common.add")}
+
+
+
-
- {t("common.add")}
-
-
-
- {/* Menu items must each carry a self-contained verb-phrase
+ {/* Menu items must each carry a self-contained verb-phrase
("Log measurement", "Log mood") — the trigger above already
says "Add", and the icon is `aria-hidden`, so the visible
text is the only thing distinguishing the rows. v1.4.15
phase-A3 fix #1 hardened this with a unit guard at
`src/app/__tests__/quick-add-labels.test.ts` — both labels
must differ from each other AND from `common.add`. */}
- onQuickEntry("measurement")}>
-
- {t("dashboard.quickAddMeasurement")}
-
- onQuickEntry("mood")}>
-
- {t("dashboard.quickAddMood")}
-
- {/* v1.4.37 W7b — third quick-add row: medication intake.
+ onQuickEntry("measurement")}>
+
+ {t("dashboard.quickAddMeasurement")}
+
+ {canManage && (
+ onQuickEntry("mood")}>
+
+ {t("dashboard.quickAddMood")}
+
+ )}
+ {/* v1.4.37 W7b — third quick-add row: medication intake.
Same Sheet-on-mobile / Dialog-on-desktop primitive as
the other two; the menu label is a self-contained
verb-phrase so it doesn't collide with the trigger or
the sibling rows (cf. quick-add-labels.test.ts). */}
- onQuickEntry("medicationIntake")}
- >
-
- {t("dashboard.quickAddMedicationIntake")}
-
- {nutrientsEnabled && (
- onQuickEntry("water")}>
-
- {t("dashboard.quickAddWater")}
+ onQuickEntry("medicationIntake")}
+ >
+
+ {t("dashboard.quickAddMedicationIntake")}
- )}
-
-
+ {nutrientsEnabled && canManage && (
+ onQuickEntry("water")}>
+
+ {t("dashboard.quickAddWater")}
+
+ )}
+
+
+ )}
>
}
/>
diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx
index f5721b897..50598c3c3 100644
--- a/src/components/medications/__tests__/card-parts.test.tsx
+++ b/src/components/medications/__tests__/card-parts.test.tsx
@@ -40,10 +40,16 @@ function makeClient(): QueryClient {
}
function render(node: React.ReactNode, client?: QueryClient) {
- const tree = client ? (
- {node}
- ) : (
- node
+ // v1.36.x — every card part now asks what the record on screen allows
+ // before it renders a control, and that answer rides the account query.
+ // The parts that carry no query of their own were rendered here without a
+ // provider; they need one now. Fixtures are the caller's own record, which
+ // is every capability — the delegation cases live in
+ // `src/components/__tests__/delegated-write-affordances.test.tsx`.
+ const tree = (
+
+ {node}
+
);
return renderToStaticMarkup(
{tree},
diff --git a/src/components/medications/card-parts/medication-intake-actions.tsx b/src/components/medications/card-parts/medication-intake-actions.tsx
index bb7dfba5d..c25cbda1a 100644
--- a/src/components/medications/card-parts/medication-intake-actions.tsx
+++ b/src/components/medications/card-parts/medication-intake-actions.tsx
@@ -2,6 +2,7 @@ import { Check, Loader2, SkipForward } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
interface MedicationIntakeActionsProps {
/** "take" | "skip" while the matching request is in flight, else null. */
@@ -27,6 +28,11 @@ export function MedicationIntakeActions({
onRecordIntake,
}: MedicationIntakeActionsProps) {
const { t } = useTranslations();
+ // 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;
return (
diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
index 9454035c6..684bb14e3 100644
--- a/src/components/medications/detail/medication-detail-tabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -31,6 +31,7 @@
* administration.
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
useCallback,
useEffect,
@@ -171,6 +172,19 @@ const TAB_SLUGS = [
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
+/**
+ * Tabs whose entire content is owner-only work: the schedule editor and its
+ * plan history, the supply ledger, the external-token row, and the advanced
+ * surface (wizard, import, pause, end, purge, delete). A delegation admits
+ * none of it at any level.
+ */
+const OWNER_ONLY_TABS: ReadonlySet = new Set([
+ "zeitplan",
+ "bestand",
+ "api",
+ "erweitert",
+]);
+
/**
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
@@ -222,6 +236,7 @@ export function MedicationDetailTabs({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const { canManage } = useRecordCapabilities();
const router = useRouter();
const searchParams = useSearchParams();
@@ -273,15 +288,23 @@ export function MedicationDetailTabs({
// reminds, so the Zeitplan tab (times editor + reminder card +
// plan-history) does not apply and is dropped from the registry; a
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
+ // v1.36.x — four tabs are nothing BUT owner work: the schedule editor, the
+ // supply ledger, the external-token row and the advanced surface that holds
+ // the wizard, the import and the destructive zone. None of it is delegated,
+ // so inside somebody else's record they leave the registry entirely rather
+ // than opening onto controls the server refuses — which also means a stale
+ // `?tab=erweitert` deep link lands on the landing tab, the same way an
+ // inapplicable tab already does.
const availableTabs = useMemo(
() =>
TAB_SLUGS.filter(
(slug) =>
(slug !== "injektion" || isInjectable) &&
(slug !== "zeitplan" || !asNeeded) &&
- (slug !== "wirkung" || hasEfficacyTarget),
+ (slug !== "wirkung" || hasEfficacyTarget) &&
+ (canManage || !OWNER_ONLY_TABS.has(slug)),
),
- [isInjectable, asNeeded, hasEfficacyTarget],
+ [isInjectable, asNeeded, hasEfficacyTarget, canManage],
);
const requestedRaw = searchParams?.get("tab");
diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx
index ef6567479..829796da5 100644
--- a/src/components/medications/dose-history-ledger.tsx
+++ b/src/components/medications/dose-history-ledger.tsx
@@ -39,6 +39,7 @@
* the ledger uses the semantic feedback vocabulary on the row, not the card.
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -162,6 +163,10 @@ export function DoseHistoryLedger({
const fmt = useFormatters();
const dateFormatPref = useDateFormatPreference();
const { user } = useAuth();
+ // 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 queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
@@ -413,16 +418,18 @@ export function DoseHistoryLedger({
{t("medications.detail.verlauf.summaryEmpty")}
)}
- setAddOpen(true)}
- className="min-h-11 shrink-0 sm:min-h-9"
- data-slot="ledger-add"
- >
-
- {t("medications.detail.verlauf.add")}
-
+ {canAdd && (
+ setAddOpen(true)}
+ className="min-h-11 shrink-0 sm:min-h-9"
+ data-slot="ledger-add"
+ >
+
+ {t("medications.detail.verlauf.add")}
+
+ )}