From 8669df4cda0c69121f9d01501e2163b27b3ded71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 5 Aug 2026 23:58:30 +0200 Subject: [PATCH] feat: add scoped record invitations --- .../__tests__/grant-invite-expiry.test.tsx | 54 ++++++++---- .../settings/access/grant-invite-card.tsx | 87 +++++++++++++++---- .../integration/sharing-scope-routes.test.ts | 54 ++++++++++++ 3 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 tests/integration/sharing-scope-routes.test.ts diff --git a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx index 89b59ba23..9d7c16213 100644 --- a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx +++ b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx @@ -1,4 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/lib/i18n/context"; @@ -36,30 +38,52 @@ function render(node: React.ReactNode): string { } describe("grant invitation lapse date", () => { - it("offers a date control on the invitation form", () => { + it("uses the shared date field on the invitation form", () => { const html = render(); expect(html).toContain('data-slot="grant-invite-expires"'); - expect(html).toContain('type="date"'); + expect(html).toContain('data-slot="date-field"'); + expect(html).not.toContain('type="date"'); }); - it("maps a chosen day to the end of that day, not its midnight", () => { - const iso = endOfDayIso("2026-09-30"); - expect(iso).not.toBeNull(); - const end = new Date(iso as string); - // Read back in the same zone the mapping used, so the assertion holds - // wherever the suite runs rather than only under the pinned UTC clock. - expect(end.getFullYear()).toBe(2026); - expect(end.getMonth()).toBe(8); - expect(end.getDate()).toBe(30); - expect(end.getHours()).toBe(23); - expect(end.getMinutes()).toBe(59); + it("maps a chosen day to the owner's local end of day", () => { + expect(endOfDayIso("2026-03-29", "America/New_York")).toBe( + "2026-03-30T03:59:59.999Z", + ); + expect(endOfDayIso("2026-10-25", "Europe/Berlin")).toBe( + "2026-10-25T22:59:59.999Z", + ); }); it("treats an empty field as no lapse date", () => { - expect(endOfDayIso("")).toBeNull(); + expect(endOfDayIso("", "Europe/Berlin")).toBeNull(); }); it("refuses a value that is not a date rather than sending an invalid instant", () => { - expect(endOfDayIso("not-a-date")).toBeNull(); + expect(endOfDayIso("not-a-date", "Europe/Berlin")).toBeNull(); + }); + + it("announces retryable failures without clearing the invitation", () => { + const source = readFileSync( + join( + process.cwd(), + "src/components/settings/access/grant-invite-card.tsx", + ), + "utf8", + ); + const onError = source.slice( + source.indexOf("onError: (err) => setError"), + source.indexOf( + " );\n };", + source.indexOf("onError: (err) => setError"), + ), + ); + + expect(onError).toContain("setError(t(inviteErrorKey(err)))"); + expect(onError).not.toContain("setIdentifier"); + expect(onError).not.toContain("setExpiresOn"); + expect(source).toContain('role="alert"'); + expect(source).toContain( + "invite.isPending || identifier.trim().length === 0", + ); }); }); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index 568af2816..eeac08f28 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -7,13 +7,18 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { useAuth } from "@/hooks/use-auth"; +import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { ApiError } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { useInviteGrant } from "@/lib/queries/use-account-grants"; import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; +import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; +import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; /** * v1.37.0 — offering somebody access to this record. @@ -72,6 +77,7 @@ import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; export function GrantInviteCard() { const { t } = useTranslations(); const invite = useInviteGrant(); + const ownerTimezone = useInvitationOwnerTimezone(); const [identifier, setIdentifier] = useState(""); const [expiresOn, setExpiresOn] = useState(""); // READ preselected. The narrower level is the one somebody should have to @@ -119,7 +125,7 @@ export function GrantInviteCard() { identifier: value, access, scope: choice.scope, - expiresAt: endOfDayIso(expiresOn), + expiresAt: endOfDayIso(expiresOn, ownerTimezone), }, { onSuccess: (grant) => { @@ -289,15 +295,18 @@ export function GrantInviteCard() { - setExpiresOn(e.target.value)} + min={tomorrowIso(ownerTimezone)} + onChange={setExpiresOn} + aria-describedby="grant-invite-expires-hint" /> -

+

{t("recordSharing.invite.expiresHint")}

@@ -568,24 +577,64 @@ function optionClass(selected: boolean): string { /** * The chosen day, as the instant access stops. * - * A date input yields a bare `YYYY-MM-DD`, and the grant's `expiresAt` is - * checked live against `now` on every request. Anchoring at the END of the - * chosen day in the browser's own zone is what makes "until the 30th" mean the - * 30th rather than the midnight that starts it, which is the reading anybody - * picking a date has in mind. Empty means no lapse date, which is the default - * and stays the common case. + * The shared date field yields a bare `YYYY-MM-DD`, and the grant's + * `expiresAt` is checked live against `now` on every request. Anchoring at the + * end of the chosen day in the owner's timezone makes "until the 30th" mean + * the owner's entire 30th, regardless of the browser's zone. Empty means no + * lapse date, which is the default and stays the common case. */ -export function endOfDayIso(day: string): string | null { +export function endOfDayIso(day: string, timeZone: string): string | null { if (day.length === 0) return null; - const end = new Date(`${day}T23:59:59.999`); - return Number.isNaN(end.getTime()) ? null : end.toISOString(); + if (!isValidTimezone(timeZone)) return null; + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!match) return null; + const [, year, month, date] = match; + const calendarDate = new Date( + Date.UTC(Number(year), Number(month) - 1, Number(date)), + ); + if ( + calendarDate.getUTCFullYear() !== Number(year) || + calendarDate.getUTCMonth() !== Number(month) - 1 || + calendarDate.getUTCDate() !== Number(date) + ) { + return null; + } + const end = zonedWallClockToUtc( + { + year: Number(year), + month: Number(month), + day: Number(date), + hour: 23, + minute: 59, + second: 59, + }, + timeZone, + ); + return new Date(end.getTime() + 999).toISOString(); } /** The earliest day worth offering: a grant that lapses today confers nothing. */ -function tomorrowIso(): string { - const d = new Date(); - d.setDate(d.getDate() + 1); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +function tomorrowIso(timeZone: string): string { + const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE; + const today = wallClockInTz(new Date(), zone); + const tomorrow = new Date( + Date.UTC(today.year, today.month - 1, today.day) + 86_400_000, + ); + return `${tomorrow.getUTCFullYear()}-${String( + tomorrow.getUTCMonth() + 1, + ).padStart(2, "0")}-${String(tomorrow.getUTCDate()).padStart(2, "0")}`; +} + +function useInvitationOwnerTimezone(): string { + const hasQueryClient = useQueryClientMounted(); + if (!hasQueryClient) return DEFAULT_TIMEZONE; + // The invitation surface is only available in the owner's own record. Its + // profile timezone therefore defines the calendar day an expiry names. + // eslint-disable-next-line react-hooks/rules-of-hooks + const { user } = useAuth(); + return isValidTimezone(user?.timezone ?? "") + ? (user?.timezone as string) + : DEFAULT_TIMEZONE; } /** diff --git a/tests/integration/sharing-scope-routes.test.ts b/tests/integration/sharing-scope-routes.test.ts new file mode 100644 index 000000000..ef078d988 --- /dev/null +++ b/tests/integration/sharing-scope-routes.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + acceptGrant, + grantCoversDomain, + inviteGrant, +} from "@/lib/sharing/grants"; +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `scope-route-${suffix}`, + email: `scope-route-${suffix}@example.test`, + timezone: "Europe/Berlin", + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("scoped sharing route contract", () => { + it("persists the closed route-family set and excludes record-wide routes", async () => { + expect(SHARE_DOMAINS.length).toBeGreaterThan(0); + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + + const invitation = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + scope: [...SHARE_DOMAINS], + }); + const accepted = await acceptGrant({ + grantId: invitation.id, + granteeId: delegate.id, + }); + const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: accepted.id }, + }); + + expect(stored.scopeJson).toEqual([...SHARE_DOMAINS]); + for (const domain of SHARE_DOMAINS) { + expect(grantCoversDomain(stored, domain)).toBe(true); + } + expect(grantCoversDomain(stored, "record")).toBe(false); + }); +});