diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index f6517480a..821d564c6 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -170,8 +170,16 @@ export default function MeasurementsPage() { } /> + {/* v1.36.x — the sheet answers the same question the header button + answers. `?add=` 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 + `/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. */} { setDialogOpen(open); if (!open) { diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 544d2a86c..5b4f4dda1 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -617,8 +617,11 @@ export default function MedicationsPageClient() { "Vollständig bearbeiten" reopens the wizard in edit mode from there); the list page wizard only ever creates. The wizard owns 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. */} ({ + useAuth: () => ({ + user: { + id: "delegate", + username: "delegate", + email: null, + role: "USER", + avatarUrl: null, + modules: {}, + accountAccess: mockAccessRef.value, + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +const { ChartOverlayControls, ChartOverlayControlsBody, DEFAULT_CHART_OVERLAY_PREFS, - type ChartOverlayPrefs, -} from "../chart-overlay-controls"; +} = await import("../chart-overlay-controls"); +type ChartOverlayPrefs = import("../chart-overlay-controls").ChartOverlayPrefs; /** * v1.4.18 — per-chart overlay-controls popover. @@ -48,6 +83,34 @@ describe("", () => { expect(html).toContain('aria-label="Chart overlay settings"'); }); + it("is absent inside somebody else's record, at both levels", () => { + // v1.36.x — every toggle persists through + // `PUT /api/dashboard/chart-overlay-prefs`, which resolves the caller and + // refuses under a switch, and the preference belongs to the person rather + // than to the record. Absent, not disabled: a greyed cog would still + // claim the chart is the delegate's to configure. + // + // Mutation check, run: dropping the `if (!canManage) return null` bail + // from `` → both legs go red on the trigger slot. + for (const access of ["read", "write"] as const) { + mockAccessRef.value = { + accounts: [OWNER], + active: { ...OWNER, access, canWrite: access === "write" }, + canSwitch: true, + }; + const html = renderToStaticMarkup( + withProvider( + {}} + />, + ), + ); + expect(html, `grant level: ${access}`).toBe(""); + } + mockAccessRef.value = OWN_RECORD; + }); + it("default prefs match the clean-line baseline (every toggle OFF)", () => { expect(DEFAULT_CHART_OVERLAY_PREFS).toEqual({ showTrendIndicator: false, diff --git a/src/components/charts/chart-overlay-controls.tsx b/src/components/charts/chart-overlay-controls.tsx index 93e8c19d4..d6ba6429d 100644 --- a/src/components/charts/chart-overlay-controls.tsx +++ b/src/components/charts/chart-overlay-controls.tsx @@ -13,6 +13,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { COMPARISON_BASELINES, @@ -89,8 +90,18 @@ export function ChartOverlayControls({ onChange, triggerClassName, hasComparisonData = true, -}: ChartOverlayControlsProps): ReactElement { +}: ChartOverlayControlsProps): ReactElement | null { const { t } = useTranslations(); + // The cog is gated here rather than at each of the eight charts that mount + // it, so the three chart wrappers cannot drift apart on the same question. + // Every toggle persists through `PUT /api/dashboard/chart-overlay-prefs`, + // which resolves the caller and refuses under a switch; and the preference + // belongs to the person reading the chart, not to the record — the same + // reasoning that keeps the mood tag layout off the delegable list. So a + // delegate reads the owner's chart in its stored shape and is not offered a + // dial that would silently fail. + const { canManage } = useRecordCapabilities(); + if (!canManage) return null; return ( diff --git a/src/components/daily/__tests__/priority-card.test.tsx b/src/components/daily/__tests__/priority-card.test.tsx index d588ae69f..c3a6a5df0 100644 --- a/src/components/daily/__tests__/priority-card.test.tsx +++ b/src/components/daily/__tests__/priority-card.test.tsx @@ -111,12 +111,38 @@ describe("", () => { kind: "coach_checkin", actions: [{ labelKey: "daily.action.logDose", intent: "dose.log" }], })} + onAction={() => {}} />, ); expect(html).toContain(" { + // v1.36.x — the caller withholds `onAction` inside somebody else's + // record. The mutating half of the rail must then be absent, not an + // inert button: `TodayHero` passes no handler at either grant level, and + // a rendered control that calls nothing is worse than one that 403s. + const html = render( + , + ); + expect(html).not.toContain("Log dose"); + // Navigation is untouched — reading is never what a delegation withholds. + expect(html).toContain('href="/checkups"'); + }); + it("resolves action label keys through the active locale", () => { const html = render( void; /** @@ -129,7 +134,13 @@ export function PriorityCard({ }: PriorityCardProps) { const { t } = useTranslations(); const Icon = KIND_ICON[item.kind]; - const actions = item.actions.slice(0, 3); + // Navigation always survives; a mutating action survives only when somebody + // is there to run it. That is what removes the whole non-navigation half of + // this card inside a record the caller may not change, without this file + // learning anything about grants. + const actions = item.actions + .filter((action) => action.href || onAction) + .slice(0, 3); const hue = KIND_HUE[item.kind]; // S12 — the quiet "reached" moment: the milestone card swaps the generic // fade-in for the `.milestone-reached` treatment (a soft `--success` halo @@ -187,15 +198,23 @@ export function PriorityCard({ ) : null} {actions.length > 0 ? (
- {actions.map((action) => ( - - ))} + {actions.map((action) => + action.href ? ( + + ) : onAction ? ( + + ) : null, + )}
) : null} @@ -203,6 +222,26 @@ export function PriorityCard({ ); } +/** A navigation action. Always offered — reading is never delegated away. */ +function ActionLink({ href, label }: { href: string; label: string }) { + return ( + + {label} + + ); +} + +/** + * A mutating action. `onAction` is required rather than optional so the type + * carries the rule: this button cannot be rendered without the handler that + * makes it do something. + */ function ActionButton({ action, label, @@ -211,22 +250,9 @@ function ActionButton({ }: { action: PriorityItemAction; label: string; - onAction?: (intent: string) => void; + onAction: (intent: string) => void; pending?: boolean; }) { - if (action.href) { - return ( - - {label} - - ); - } return ( - + {canManage ? ( + <> + + {/* The upload affordance is a deep link into the vault with + this episode pre-filtered. The vault's own upload control + is `canManage`-gated, so an ungated link here would send a + delegate to a page with nothing to press. */} + + + ) : null} {hasMore ? ( + {canManage ? ( + + ) : null} ); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 3f0fa6413..c5412c6da 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -983,21 +983,28 @@ function VorsorgeCard({
- + {/* The same gate the cards branch applies through + `primaryButton`. The list branch used to inline its own + ungated copy, and which branch a person sees is a per-browser + preference that survives the switch — so the identical action + was offered or withheld depending on a view toggle. */} + {canManage ? ( + + ) : null} {headerActions}
diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index 8d0ac882b..bba726adc 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -36,6 +36,7 @@ import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import type { MedicationEfficacyDTO, @@ -388,6 +389,11 @@ function RetargetControl({ onChanged: () => void; }) { const { t } = useTranslations(); + // Repointing what a medication is judged against rewrites a setting the + // owner chose — not an admitted create, and `PUT .../efficacy/target` + // resolves the caller, so it is refused at both grant levels. The Wirkung + // tab itself stays readable; only the dial goes. + const { canManage } = useRecordCapabilities(); const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); @@ -437,7 +443,7 @@ function RetargetControl({ } }; - if (items.length === 0) return null; + if (!canManage || items.length === 0) return null; return (
- void runUndoIntake({ - medication: { id: medicationId, name: medicationName }, - eventId, - t, - queryClient, - }), - }, + description: t("recordSharing.toast.savedTo", { + name: recordName, + }), } - : undefined, + : eventId + ? { + action: { + label: t("medications.intakeUndo"), + onClick: () => + void runUndoIntake({ + medication: { id: medicationId, name: medicationName }, + eventId, + t, + queryClient, + }), + }, + } + : undefined, ); await invalidateKeys(queryClient, [ ...medicationDependentKeys, @@ -314,7 +329,15 @@ export function DoseHistoryLedger({ setMarking(null); } }, - [marking, medicationId, medicationName, queryClient, queryKey, t], + [ + marking, + medicationId, + medicationName, + queryClient, + queryKey, + recordName, + t, + ], ); /** diff --git a/src/lib/insights/__tests__/coach-launch-context.test.tsx b/src/lib/insights/__tests__/coach-launch-context.test.tsx index 0fbe78781..a4054c979 100644 --- a/src/lib/insights/__tests__/coach-launch-context.test.tsx +++ b/src/lib/insights/__tests__/coach-launch-context.test.tsx @@ -1,11 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { - CoachLaunchProvider, - resolveLaunchState, - useCoachLaunch, -} from "../coach-launch-context"; +import type { AccountAccess } from "@/lib/sharing/account-access-view"; /** * v1.4.27 R3d MB4 — Coach launch context smoke contract. @@ -19,8 +15,48 @@ import { * 3. The hook returns `null` when called outside the provider — * consumers degrade gracefully (e.g. the launch button renders * nothing rather than crashing). + * 4. v1.36.x — the provider publishes nothing inside somebody else's + * record, so (3) also covers every consumer under a switch. The shell + * mounts no drawer there, and a launch call that opened nothing was a + * button that silently did nothing on every page that offers one. + * + * Mutation check, run: dropping the `inSharedRecord` arm from the provider's + * value memo → "publishes nothing inside somebody else's record" goes red + * with the full context shape in the diff. */ +const OWNER = { + accountId: "acct-owner", + username: "owner", + displayName: "Margarethe", + access: "write" as const, + canWrite: true, +}; + +const mockAccessRef: { value: AccountAccess } = { + value: { accounts: [OWNER], active: null, canSwitch: true }, +}; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "delegate", + username: "delegate", + email: null, + role: "USER", + avatarUrl: null, + modules: {}, + accountAccess: mockAccessRef.value, + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +const { CoachLaunchProvider, resolveLaunchState, useCoachLaunch } = + await import("../coach-launch-context"); + function Probe({ output }: { output: string[] }) { const launch = useCoachLaunch(); if (!launch) { @@ -72,6 +108,41 @@ describe("CoachLaunchProvider", () => { expect(html).toContain('data-slot="child-mount"'); expect(html).toContain("child"); }); + + it("publishes nothing inside somebody else's record, at both levels", () => { + for (const access of ["read", "write"] as const) { + mockAccessRef.value = { + accounts: [OWNER], + active: { ...OWNER, access, canWrite: access === "write" }, + canSwitch: true, + }; + const output: string[] = []; + renderToStaticMarkup( + + + , + ); + // The whole recorded value, not a boolean: a failure prints the shape + // that leaked rather than `false !== true`. + expect(output, `grant level: ${access}`).toEqual(["null"]); + } + mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true }; + }); + + it("still renders its children there — only the launch value is withheld", () => { + mockAccessRef.value = { + accounts: [OWNER], + active: OWNER, + canSwitch: true, + }; + const html = renderToStaticMarkup( + +
child
+
, + ); + mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true }; + expect(html).toContain('data-slot="child-mount"'); + }); }); /** diff --git a/src/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx index c8f1cbd12..fc5689729 100644 --- a/src/lib/insights/coach-launch-context.tsx +++ b/src/lib/insights/coach-launch-context.tsx @@ -10,6 +10,7 @@ import { type ReactNode, } from "react"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; /** @@ -191,7 +192,30 @@ export interface CoachLaunchProviderProps { children: ReactNode; } +/** + * Owns the drawer's launch state — and publishes it only where a drawer + * exists to receive it. + * + * v1.36.x — the shell stopped mounting `` inside somebody + * else's record, and this provider kept answering: `askCoach()` set an open + * flag that nothing was reading, so every per-page Coach entry point became a + * button that did nothing at all. A control that errors tells a person where + * they stand; one that silently does nothing tells them the product is + * broken. + * + * The gate lives here, on the publisher, rather than in `useCoachLaunch()`. + * Six components read this context and five already treat `null` as "no Coach + * here", so withholding the value fixes all of them at once and a seventh + * inherits the rule. Putting it in the hook instead would drag the account + * query into every one of those components for an answer that is the same in + * all of them. + * + * Nothing is taken away by this: `/insights` and `/coach` are not + * shared-record destinations, so the Coach is outside what sharing covers to + * begin with. + */ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { + const { inSharedRecord } = useRecordCapabilities(); const [open, setOpen] = useState(false); const [closeIntent, setCloseIntent] = useState(null); const [prefill, setPrefill] = useState(null); @@ -277,20 +301,24 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { [], ); - const value = useMemo( - () => ({ - open, - closeIntent, - prefill, - autoSend, - scope, - documentId, - workoutId, - askCoach, - registerScope, - setOpen: handleSetOpen, - }), + const value = useMemo( + () => + inSharedRecord + ? null + : { + open, + closeIntent, + prefill, + autoSend, + scope, + documentId, + workoutId, + askCoach, + registerScope, + setOpen: handleSetOpen, + }, [ + inSharedRecord, closeIntent, open, prefill, @@ -316,6 +344,12 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { * `` so consumer components can degrade gracefully * (e.g. the hero strip's "Ask the coach" action stays disabled until * the provider mounts). + * + * v1.36.x — also `null` inside somebody else's record, and by the same + * mechanism: the provider publishes nothing there (see its own docblock), so + * every consumer's existing `if (!launch) return null` becomes right without + * being told. Kept as a plain context read on purpose — the answer is decided + * once, in the provider, rather than by a hook that six components call. */ export function useCoachLaunch(): CoachLaunchValue | null { return useContext(CoachLaunchContext);