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(
+
{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); + }); +});