feat: add scoped record invitations

This commit is contained in:
Marc-André Bombeck committed 2026-08-05 23:58:30 +02:00
1 parent 4923db6464
commit 8669df4cda
3 files changed
+161 -34

No files matched your search

@@ -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(<GrantInviteCard />);
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",
);
});
});
@@ -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() {
<Label htmlFor="grant-invite-expires">
{t("recordSharing.invite.expiresLabel")}
</Label>
<Input
<DateField
id="grant-invite-expires"
data-slot="grant-invite-expires"
type="date"
value={expiresOn}
min={tomorrowIso()}
onChange={(e) => setExpiresOn(e.target.value)}
min={tomorrowIso(ownerTimezone)}
onChange={setExpiresOn}
aria-describedby="grant-invite-expires-hint"
/>
<p className="text-muted-foreground text-xs">
<p
id="grant-invite-expires-hint"
className="text-muted-foreground text-xs"
>
{t("recordSharing.invite.expiresHint")}
</p>
</div>
@@ -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;
}
/**
@@ -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);
});
});