fix(cycle): keep the calendar feed to the owner and a managed profile's guardian

A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.

The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.

The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 18:03:37 +01:00
1 parent e8ef590af7
commit 3b3ef5c0a1
18 files changed
+227 -171

No files matched your search

@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const caps = vi.hoisted(() => ({
value: { inSharedRecord: false, recordKind: "self", level: null } as {
inSharedRecord: boolean;
recordKind: string;
level: string | null;
},
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => caps.value,
}));
vi.mock("../cycle-settings", () => ({
CycleSettings: () => <div data-slot="cycle-settings-form" />,
}));
vi.mock("../cycle-calendar-feed-card", () => ({
CycleCalendarFeedCard: () => <div data-slot="cycle-calendar-feed" />,
}));
import { CycleSettingsTab } from "../cycle-settings-tab";
import type { CycleProfileDTO } from "../types";
const PROFILE = { updatedAt: "2026-11-01T00:00:00.000Z" } as CycleProfileDTO;
function render(value: typeof caps.value) {
caps.value = value;
return renderToStaticMarkup(<CycleSettingsTab profile={PROFILE} />);
}
describe("<CycleSettingsTab> calendar subscription (#1350)", () => {
it("offers it on one's own record", () => {
expect(
render({ inSharedRecord: false, recordKind: "self", level: null }),
).toContain("cycle-calendar-feed");
});
it("offers it to the guardian of a managed profile", () => {
expect(
render({ inSharedRecord: true, recordKind: "managed", level: "manage" }),
).toContain("cycle-calendar-feed");
});
it("does not offer it to a MANAGE delegate on an adult's record", () => {
const html = render({
inSharedRecord: true,
recordKind: "shared",
level: "manage",
});
expect(html).toContain("cycle-settings-form");
expect(html).not.toContain("cycle-calendar-feed");
});
});
+11 -1
View File
@@ -1,5 +1,7 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card";
import { CycleSettings } from "./cycle-settings";
import type { CycleProfileDTO } from "./types";
@@ -8,12 +10,20 @@ import type { CycleProfileDTO } from "./types";
* The cycle page's settings tab: the profile form, then the calendar
* subscription (#1350). The form remounts on a profile refetch (`key`), the
* subscription card keeps its own state, including a link it just showed.
*
* The card is the owner's on their own record, and a guardian's on a managed
* profile they manage: the routes behind it (`requireGuardianAuth`) refuse a
* delegate on an adult's record at every level, so the card is not offered
* there rather than opening onto a refusal.
*/
export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) {
const { inSharedRecord, recordKind, level } = useRecordCapabilities();
const mayHoldFeed =
!inSharedRecord || (recordKind === "managed" && level === "manage");
return (
<div className="space-y-4">
<CycleSettings key={profile.updatedAt} profile={profile} />
<CycleCalendarFeedCard />
{mayHoldFeed ? <CycleCalendarFeedCard /> : null}
</div>
);
}