Withhold the controls a shared record refuses

Nine surfaces offered a delegate an action the server declines, and the
switch had never been asked about any of them.

The Today rail's dismiss and the coach check-in's keep / let-go write
through routes that resolve the caller, so both refuse under a switch;
they now ask canManage and are absent at either grant level. A priority
action with no href and no handler is no longer rendered as an inert
button at all — the type carries that rule now.

The Vorsorge dashboard card offered the mark-done that /checkups already
withholds, and the checkups list branch inlined its own ungated copy of
the button the cards branch gates, so which view a browser last chose
decided whether the action appeared. Both bind canManage.

The chart overlay cog persists through a route that resolves the caller
and stores a preference belonging to the person rather than the record.
Gated once in the control so the three chart wrappers cannot drift.

Three query parameters opened sheets past a gated button: ?add=<TYPE> on
measurements, ?new=1 on medications, and ?edit=1 on a medication detail,
the last of which opens a wizard refused at both levels. A deep link is
the same affordance as the control that produces it.

The efficacy retarget dial rewrites a setting the owner chose. The
episode documents card offered a link and an upload the vault gates on
the same endpoint throughout. And the dose ledger built its own success
toast, missing both the Undo suppression and the "saved to" receipt its
two siblings already carry — the one a delegate met on every dose.

The Coach drawer is not mounted inside a shared record, so every button
calling askCoach() opened nothing. The launch provider now publishes no
value there and each entry point's existing null check does the rest;
the documents sheet, the one that rendered without checking, now checks.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 18:33:48 +02:00
1 parent 1d28aeecf9
commit 45363edb77
16 files changed
+452 -126

No files matched your search

+9 -1
View File
@@ -170,8 +170,16 @@ export default function MeasurementsPage() {
}
/>
{/* v1.36.x — the sheet answers the same question the header button
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
`/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}
open={dialogOpen && canAdd}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
+4 -1
View File
@@ -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. */}
<MedicationWizardDialog
open={dialogOpen}
open={dialogOpen && canAdd}
onOpenChange={setDialogOpen}
mode="create"
onSuccess={closeDialog}
@@ -1,13 +1,48 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
import {
const OWNER = {
accountId: "acct-owner",
username: "owner",
displayName: "Margarethe",
access: "write" as const,
canWrite: true,
};
const OWN_RECORD: AccountAccess = {
accounts: [OWNER],
active: null,
canSwitch: true,
};
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
// The cog asks `useRecordCapabilities`, which reads the account payload.
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 {
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("<ChartOverlayControls>", () => {
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 `<ChartOverlayControls>` → 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(
<ChartOverlayControls
prefs={DEFAULT_CHART_OVERLAY_PREFS}
onChange={() => {}}
/>,
),
);
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,
@@ -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 (
<DropdownMenu>
@@ -111,12 +111,38 @@ describe("<PriorityCard>", () => {
kind: "coach_checkin",
actions: [{ labelKey: "daily.action.logDose", intent: "dose.log" }],
})}
onAction={() => {}}
/>,
);
expect(html).toContain("<button");
expect(html).toContain("Log dose");
});
it("omits a non-navigation action entirely when no handler is passed", () => {
// 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(
<PriorityCard
item={item({
kind: "coach_checkin",
actions: [
{ labelKey: "daily.action.logDose", intent: "dose.log" },
{
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: "/checkups",
},
],
})}
/>,
);
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(
<PriorityCard
+50 -24
View File
@@ -95,6 +95,11 @@ export interface PriorityCardProps {
* Tap handler for non-navigation actions. Receives the action's stable
* `intent` token. Navigation actions (those carrying an `href`) render as
* links and do not call this.
*
* Omitting it does not render an inert button: an action with no `href` and
* no handler is not offered at all. A button whose only job is to call a
* handler nobody passed is a control that says "this works" and does
* nothing, which is the one outcome worse than a refusal.
*/
onAction?: (intent: string) => 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 ? (
<div className="flex flex-wrap items-center gap-2 pt-1">
{actions.map((action) => (
<ActionButton
key={action.intent}
action={action}
label={t(action.labelKey)}
onAction={onAction}
pending={actionsPending}
/>
))}
{actions.map((action) =>
action.href ? (
<ActionLink
key={action.intent}
href={action.href}
label={t(action.labelKey)}
/>
) : onAction ? (
<ActionButton
key={action.intent}
action={action}
label={t(action.labelKey)}
onAction={onAction}
pending={actionsPending}
/>
) : null,
)}
</div>
) : null}
</CardContent>
@@ -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 (
<Link
href={href}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
ACTION_SIZE,
)}
>
{label}
</Link>
);
}
/**
* 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 (
<Link
href={action.href}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
ACTION_SIZE,
)}
>
{label}
</Link>
);
}
return (
<Button
variant="outline"
+15 -2
View File
@@ -43,6 +43,7 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
import { PriorityCard } from "@/components/daily/priority-card";
import { useCoachCheckinAction } from "@/hooks/use-coach-checkin";
import { usePriorityItemDismiss } from "@/hooks/use-priority-item-dismiss";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DailyDigest } from "@/lib/daily/digest";
@@ -99,6 +100,14 @@ export function TodayHero({
// the reason a mount gate lived here at all.
const { keep, letGo } = useCoachCheckinAction();
const dismissItem = usePriorityItemDismiss();
// The rail's two mutating affordances — dismissing an observation and
// answering a coach check-in — are neither of them an admitted create, so
// they ask `canManage` and are absent at both grant levels. Both write
// through routes that resolve the CALLER (`POST /api/daily/digest/dismiss`,
// `PATCH /api/coach/plans/[id]`), which under a switch means a 403 rather
// than a stray row; the control still goes, because an affordance the
// server is on record refusing should not be on the page.
const { canManage } = useRecordCapabilities();
// The coach check-in card's keep / let-go intents carry the plan id after the
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
@@ -325,8 +334,12 @@ export function TodayHero({
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={handleAction}
onDismiss={(itemKey) => dismissItem.mutate(itemKey)}
onAction={canManage ? handleAction : undefined}
onDismiss={
canManage
? (itemKey) => dismissItem.mutate(itemKey)
: undefined
}
actionsPending={
item.kind === "coach_checkin" &&
(keep.isPending || letGo.isPending)
@@ -533,7 +533,12 @@ export function DocumentDetailSheet({
<span />
)}
<div className="flex items-center gap-2">
{aiEnabled ? (
{/* v1.36.x — `launch` is the one entry point that rendered
without asking whether a drawer exists to open. It is null
inside somebody else's record (the shell mounts no drawer
there), and `/documents` is a shared-record destination, so
this is the Coach button a delegate actually meets. */}
{aiEnabled && launch ? (
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
// coach conversation scoped to this document in the SIDE
// DRAWER (the maximize control there expands it to the full
@@ -545,7 +550,7 @@ export function DocumentDetailSheet({
variant="outline"
onClick={() => {
onOpenChange(false);
launch?.askCoach(null, undefined, false, doc.id);
launch.askCoach(null, undefined, false, doc.id);
}}
data-slot="document-chat-open"
aria-label={t("documents.chat.openAria")}
@@ -26,6 +26,7 @@ import { ListRow } from "@/components/ui/list-row";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -46,6 +47,12 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const format = useFormatters();
const { user } = useAuth();
// Linking a document to an episode writes through
// `POST /api/documents/inbound/bulk`, the same endpoint the vault gates on
// `canManage` throughout. The episode side of the same link had no gate at
// all, so the vault refused what this card offered. Reading the linked
// documents stays: the list rows and "show all" are pure navigation.
const { canManage } = useRecordCapabilities();
const [pickerOpen, setPickerOpen] = useState(false);
const moduleEnabled = user?.modules?.inboundDocuments === true;
@@ -139,20 +146,28 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
)}
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Link2 className="size-4" aria-hidden />
{t("documents.episodeCard.link")}
</Button>
<Button asChild variant="outline" size="sm">
<Link href={vaultHref}>
<Upload className="size-4" aria-hidden />
{t("documents.episodeCard.upload")}
</Link>
</Button>
{canManage ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Link2 className="size-4" aria-hidden />
{t("documents.episodeCard.link")}
</Button>
{/* 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. */}
<Button asChild variant="outline" size="sm">
<Link href={vaultHref}>
<Upload className="size-4" aria-hidden />
{t("documents.episodeCard.upload")}
</Link>
</Button>
</>
) : null}
{hasMore ? (
<Button
asChild
@@ -169,11 +184,13 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
</CardContent>
</Card>
<DocumentLinkPicker
episodeId={episodeId}
open={pickerOpen}
onOpenChange={setPickerOpen}
/>
{canManage ? (
<DocumentLinkPicker
episodeId={episodeId}
open={pickerOpen}
onOpenChange={setPickerOpen}
/>
) : null}
</>
);
}
@@ -19,6 +19,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -54,6 +55,12 @@ export function VorsorgeDashboardCard() {
// phrase in, so the two screens still agree when the device sits elsewhere.
const displayTz = useDisplayTimezone();
const router = useRouter();
// The same answer `/checkups` gives the same action. Satisfying a reminder
// is not one of the verbs a delegation admits at any level
// (`POST /api/measurement-reminders/[id]/satisfy` resolves the caller), and
// the dashboard summary offering what the dedicated page withholds was the
// tell that this row had never been asked.
const { canManage } = useRecordCapabilities();
const { data: reminders, isLoading } = useMeasurementReminders();
const { satisfy } = useMeasurementReminderMutations();
const [now] = useState(() => Date.now());
@@ -137,29 +144,31 @@ export function VorsorgeDashboardCard() {
})}
</Badge>
</div>
<Button
type="button"
size="default"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => onPrimaryAction(reminder)}
disabled={satisfy.isPending}
>
{isLinked ? (
<>
<Plus className="h-4 w-4" />
{t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.captureValue",
)}
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
{t("measurementReminders.markDone")}
</>
)}
</Button>
{canManage ? (
<Button
type="button"
size="default"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => onPrimaryAction(reminder)}
disabled={satisfy.isPending}
>
{isLinked ? (
<>
<Plus className="h-4 w-4" />
{t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.captureValue",
)}
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
{t("measurementReminders.markDone")}
</>
)}
</Button>
) : null}
</li>
</ListRow>
);
@@ -983,21 +983,28 @@ function VorsorgeCard({
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onPrimaryAction}
disabled={busy}
>
{isLinked
? t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.measureNow",
)
: t("measurementReminders.markDone")}
</Button>
{/* 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 ? (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onPrimaryAction}
disabled={busy}
>
{isLinked
? t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.measureNow",
)
: t("measurementReminders.markDone")}
</Button>
) : null}
{headerActions}
</div>
</CardContent>
@@ -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<string>("");
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 (
<div
@@ -893,8 +893,12 @@ export function MedicationDetailTabs({
{/* Structural edit — the wizard stays the name / class / cadence-kind
/ schedule tool. Hydrates from the medication payload in edit
mode; closing it leaves the user on the same tab. */}
{/* v1.36.x — `?edit=1` opens this wizard straight from the list kebab.
Correcting an existing medication is refused at both grant levels
(`PUT /api/medications/[id]` is not delegable), so the deep link is
gated exactly like the hero button it stands in for. */}
<MedicationWizardDialog
open={editOpen}
open={editOpen && canManage}
onOpenChange={setEditOpen}
mode="edit"
initial={payload}
@@ -39,7 +39,10 @@
* the ledger uses the semantic feedback vocabulary on the row, not the card.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
useActiveRecordName,
useRecordCapabilities,
} from "@/hooks/use-record-capabilities";
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -167,6 +170,12 @@ export function DoseHistoryLedger({
// 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();
// 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
// both, so a delegate met an Undo the server refuses on every dose they
// marked — the most-encountered of the set.
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
@@ -282,20 +291,26 @@ export function DoseHistoryLedger({
: "medications.intakeToastTaken",
{ name: medicationName },
),
eventId
recordName
? {
action: {
label: t("medications.intakeUndo"),
onClick: () =>
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,
],
);
/**
@@ -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(
<CoachLaunchProvider>
<Probe output={output} />
</CoachLaunchProvider>,
);
// 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(
<CoachLaunchProvider>
<div data-slot="child-mount">child</div>
</CoachLaunchProvider>,
);
mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true };
expect(html).toContain('data-slot="child-mount"');
});
});
/**
+47 -13
View File
@@ -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 `<LayoutCoachMount>` 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<boolean>(false);
const [closeIntent, setCloseIntent] = useState<CoachCloseIntent | null>(null);
const [prefill, setPrefill] = useState<string | null>(null);
@@ -277,20 +301,24 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
[],
);
const value = useMemo<CoachLaunchValue>(
() => ({
open,
closeIntent,
prefill,
autoSend,
scope,
documentId,
workoutId,
askCoach,
registerScope,
setOpen: handleSetOpen,
}),
const value = useMemo<CoachLaunchValue | null>(
() =>
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) {
* `<CoachLaunchProvider>` 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);