From 533400ce60d4cc058c4441f19f2cbac23fd25c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:12:08 +0200 Subject: [PATCH 1/4] Let an invitation carry the level it offers A grant has always had an access column and `inviteGrant` has always written READ into it. That was the right way to ship the first version: "this release never grants write" was a property of the file rather than a promise in a comment. Remove it deliberately now, so somebody sharing their record can offer reading or reading plus adding. The level is a required argument to `inviteGrant` and is defaulted in exactly one place, the invite schema, where an omitted field becomes READ. An older client's payload therefore stays valid and stays narrow, and the domain module never guesses a level nobody named. There is no upgrade path and that is the decision, not a gap. A grant is READ or WRITE from the moment it is offered and stays that way until somebody ends it. Widening a live one would change what the delegate agreed to without asking them again, and their consent is half of what makes a write grant legitimate. The way up is a new invitation, accepted again. The audit row and the wide event carry the level: "who was given what, and by whom" is the question that row exists to answer. --- docs/api/openapi.yaml | 28 +++- .../__tests__/invite-access-level.test.ts | 155 ++++++++++++++++++ src/app/api/account/grants/route.ts | 26 ++- src/lib/openapi/routes/account-sharing.ts | 8 +- src/lib/sharing/__tests__/grants.test.ts | 81 +++++++++ src/lib/sharing/account-access.ts | 11 +- src/lib/sharing/grants.ts | 47 ++++-- src/lib/validations/account-sharing.ts | 21 ++- .../account-grant-lifecycle.test.ts | 68 +++++++- .../acting-account-resolver.test.ts | 8 +- tests/integration/sharing-audit-actor.test.ts | 2 + 11 files changed, 408 insertions(+), 47 deletions(-) create mode 100644 src/app/api/account/grants/__tests__/invite-access-level.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dd594f976..aa2d46dc1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12507,12 +12507,12 @@ paths: post: tags: - Account sharing - summary: Invite an account to read this record - description: Offers read access to somebody already registered on this instance; the grant confers nothing until they - accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller - on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a - mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor - re-delegate. + summary: Invite an account to this record + description: Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing + until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an + authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a + silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can + neither invite nor re-delegate. requestBody: required: true content: @@ -12530,6 +12530,17 @@ paths: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + access: + default: READ + description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, + observations, a medication, a marked dose) and can still edit or delete nothing, including its own. + Omitted means READ, so a client that predates the field keeps working. The level is fixed when the + invitation is written: no endpoint raises a live grant, because that would widen what the delegate + accepted without asking them again. The way up is a new invitation the delegate accepts." + type: string + enum: + - READ + - WRITE required: - identifier responses: @@ -36689,8 +36700,9 @@ components: - write canWrite: type: boolean - description: Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; - never derive it. + description: "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It + never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never + derive it." required: - accountId - username diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts new file mode 100644 index 000000000..2507d194e --- /dev/null +++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts @@ -0,0 +1,155 @@ +/** + * `POST /api/account/grants` — the level a request carries is the level the row + * gets. + * + * The assertion is deliberately on the WRITE the route performs rather than on + * the JSON it answers with: a response echoing the request would pass either + * way, and the failure this pins is the one where a field is validated, + * annotated on, and then dropped on the way to the database. So every case + * reads the `data` object handed to `accountGrant.create`. + * + * Where the default lives matters and is asserted here rather than assumed: an + * omitted `access` becomes READ in the invite schema and nowhere else — + * `inviteGrant` takes the level as a required argument. Move the default and + * the third case below goes red. + * + * What this file cannot see: the browser. That the level a person picks on the + * invitation form reaches this route at all is proven in + * `e2e/account-sharing.spec.ts`, which reads the posted body — the component + * test renders the control and stays green with the control wired to a + * constant. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>(h: T): T => + h, + requireAuth: vi.fn(), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + accountGrant: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { POST } from "../route"; +import { requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; + +const OWNER = { id: "owner-1" }; +const INVITEE = { id: "invitee-1", username: "housemate", displayName: null }; + +function request(body: Record): NextRequest { + return new NextRequest("http://localhost/api/account/grants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** The `data` object the route's write reached the client with. */ +function writtenRow(): Record { + const calls = vi.mocked(prisma.accountGrant.create).mock.calls; + expect(calls).toHaveLength(1); + return (calls[0][0] as { data: Record }).data; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ + user: OWNER, + } as unknown as Awaited>); + vi.mocked(prisma.user.findFirst).mockResolvedValue( + INVITEE as never as Awaited>, + ); + vi.mocked(prisma.accountGrant.create).mockImplementation((async (args: { + data: Record; + }) => ({ + id: "grant-1", + grantorId: OWNER.id, + granteeId: INVITEE.id, + acceptedAt: null, + revokedAt: null, + revokedBy: null, + lastUsedAt: null, + expiresAt: null, + ...args.data, + })) as never); +}); + +describe("invitation access level", () => { + it("stores WRITE when the invitation offers it", async () => { + const res = await POST( + request({ identifier: "housemate", access: "WRITE" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("WRITE"); + const body = (await res.json()) as { data: { access: string } }; + expect(body.data.access).toBe("WRITE"); + }); + + it("stores READ when the invitation offers it", async () => { + const res = await POST( + request({ identifier: "housemate", access: "READ" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("READ"); + }); + + it("mints READ for a client that does not send the field", async () => { + // An older client's payload stays valid, and it stays NARROW. This is the + // case that must never widen by accident. + const res = await POST(request({ identifier: "housemate" })); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("READ"); + }); + + it("refuses a level that is not one of the two, without writing a row", async () => { + const res = await POST( + request({ identifier: "housemate", access: "ADMIN" }), + ); + + expect(res.status).toBe(422); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("records the level on the audit row", async () => { + // "Who was given what, and by whom" is the question this row answers. An + // entry naming the invitation without its level answers half of it. + await POST(request({ identifier: "housemate", access: "WRITE" })); + + expect(auditLog).toHaveBeenCalledWith( + "sharing.grant.invited", + expect.objectContaining({ + userId: OWNER.id, + details: expect.objectContaining({ access: "WRITE" }), + }), + ); + }); +}); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 8d5c785a8..8e92d7c15 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -6,7 +6,10 @@ * one question — "who can see my record, and whose can I see" — and because a * client that has to call twice will eventually render half an answer. * - * `POST` offers a grant to somebody already registered on this instance. + * `POST` offers a grant to somebody already registered on this instance, at + * the level the owner chose. There is no route that raises a live grant: the + * level is settled when the invitation is written and the only way to a wider + * one is a fresh invitation the delegate accepts again. * * **Neither is delegable, and that is structural.** Both resolve through bare * `requireAuth()`, which refuses outright while the caller is acting on @@ -111,7 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { identifier, expiresAt } = parsed.data; + const { identifier, expiresAt, access } = parsed.data; // Either column, case-insensitively — the same lookup the login form does, // so the name somebody signs in with is the name they can be invited by. @@ -129,18 +132,30 @@ export const POST = apiHandler(async (request: NextRequest) => { } try { - // `inviteGrant` decides everything about the row: READ and only READ, the + // `inviteGrant` decides everything about the row except the level: the // self-grant refusal, and the one-live-grant-per-pair rule the partial - // unique index backs. None of it is re-decided here. + // unique index backs. Neither is re-decided here. The level travels from + // the parsed body, where the schema is the one place an omitted field + // becomes READ, and it is fixed for the life of the row — the domain + // module has no transition that raises it. const grant = await inviteGrant({ grantorId: user.id, granteeId: invitee.id, + access, expiresAt: expiresAt ? new Date(expiresAt) : null, }); + // The level is on the audit row, not only in the response: "who was given + // what, and by whom" is the question this row exists to answer, and an + // entry that recorded the invitation without its level would answer half + // of it. await auditLog("sharing.grant.invited", { userId: user.id, - details: { grantId: grant.id, granteeId: invitee.id }, + details: { + grantId: grant.id, + granteeId: invitee.id, + access: grant.access, + }, ipAddress: getClientIp(request), }).catch(() => {}); @@ -148,6 +163,7 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "sharing.grant.invited" }, meta: { grant_id: grant.id, + access: grant.access, expires: expiresAt !== null && expiresAt !== undefined, }, }); diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index d8224ce36..7ec3bb253 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -32,7 +32,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; inviteGrantSchema.meta({ id: "AccountGrantInvite", description: - "Offer read access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. The access level is not a parameter: v1 grants READ only.", + "Offer access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: READ can read the record and change nothing, WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. The level is fixed when the invitation is written — no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", }); switchAccountSchema.meta({ @@ -205,7 +205,7 @@ const accountAccessEntry = z canWrite: z .boolean() .describe( - "Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; never derive it.", + "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never derive it.", ), }) .meta({ @@ -327,9 +327,9 @@ export const accountSharingPaths: NonNullable = { }, post: { tags: ["Account sharing"], - summary: "Invite an account to read this record", + summary: "Invite an account to this record", description: - "Offers read access to somebody already registered on this instance; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", + "Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", requestBody: { required: true, content: { "application/json": { schema: inviteGrantSchema } }, diff --git a/src/lib/sharing/__tests__/grants.test.ts b/src/lib/sharing/__tests__/grants.test.ts index f0708338d..86153cf7a 100644 --- a/src/lib/sharing/__tests__/grants.test.ts +++ b/src/lib/sharing/__tests__/grants.test.ts @@ -16,9 +16,11 @@ import { describe, expect, it } from "vitest"; import { grantAllows, grantState, + inviteGrant, isGrantActive, type GrantLifecycle, } from "@/lib/sharing/grants"; +import type { AccountGrant, Prisma } from "@/generated/prisma/client"; const NOW = new Date("2026-08-02T12:00:00.000Z"); const EARLIER = new Date("2026-08-01T12:00:00.000Z"); @@ -131,3 +133,82 @@ describe("grantAllows", () => { } }); }); + +/** + * The level the caller offered is the level the row is written with. + * + * A capture client rather than a database: what is asserted here is the `data` + * object this module hands the client, which is the one thing a fake can see + * honestly. That the column then holds the value, that the enum accepts it and + * that the partial unique index still applies are properties of Postgres and + * live in `tests/integration/account-grant-lifecycle.test.ts` — a fake that + * returned whatever it was given would report all three as working. + * + * There is no upgrade test because there is no upgrade: no function in this + * module raises the level of a row that exists, and the way to a wider grant + * is a new invitation the delegate accepts again. + */ +describe("inviteGrant", () => { + function captureDb(): { + db: Pick; + writes: Array<{ access: string; grantorId: string; granteeId: string }>; + } { + const writes: Array<{ + access: string; + grantorId: string; + granteeId: string; + }> = []; + const db = { + accountGrant: { + create: async (args: { + data: { access: string; grantorId: string; granteeId: string }; + }) => { + writes.push(args.data); + return { id: "grant-1", ...args.data } as unknown as AccountGrant; + }, + }, + } as unknown as Pick; + return { db, writes }; + } + + it("writes the level it was given", async () => { + for (const access of ["READ", "WRITE"] as const) { + const { db, writes } = captureDb(); + const row = await inviteGrant( + { grantorId: "owner", granteeId: "delegate", access }, + db, + ); + expect(writes).toHaveLength(1); + expect(writes[0].access).toBe(access); + expect(row.access).toBe(access); + } + }); + + it("still refuses a self-grant before anything is written", async () => { + const { db, writes } = captureDb(); + await expect( + inviteGrant( + { grantorId: "solo", granteeId: "solo", access: "WRITE" }, + db, + ), + ).rejects.toMatchObject({ code: "self_grant" }); + expect(writes).toHaveLength(0); + }); + + it("confers the offered level only after acceptance", () => { + // The two halves of the write consent, as the state machine sees them: the + // owner's is the row, the delegate's is `acceptedAt`. Neither alone is + // enough, and the pending row proves it. + const offered = { + revokedAt: null, + expiresAt: null, + access: "WRITE" as const, + }; + expect(grantAllows({ ...offered, acceptedAt: null }, "write", NOW)).toBe( + false, + ); + expect(grantAllows({ ...offered, acceptedAt: EARLIER }, "write", NOW)).toBe( + true, + ); + }); +}); diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 1a0fe3f54..74793b625 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -14,11 +14,12 @@ * `canSwitch` and `canWrite`; it never computes them, and there is nothing in * the payload it could compute them from. * - * `canWrite` is `false` for every entry in v1, because `inviteGrant` writes - * READ and only READ. It is resolved here through {@link grantAllows} against - * the real row rather than hardcoded, so the day a WRITE grant exists the - * answer changes without this file being edited — and, more importantly, so - * that nobody has to remember that it should. + * `canWrite` is resolved here through {@link grantAllows} against the real + * row rather than stored or hardcoded, which is why the level becoming an + * invitation choice needed no edit to this file: an accepted WRITE grant + * started answering true the moment one could exist. It answers "may add to + * this record" and never "may change it" — editing and deleting stay with the + * owner at both levels, and no field here offers them. * * What is deliberately NOT published: the other account's avatar. The avatar * bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index fb387b469..c3765a3be 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -2,10 +2,11 @@ * v1.36.0 — the account-grant state machine. One file, one set of rules. * * A grant is one account's standing permission to read another account's - * record, and the row is also the durable record of the consent that created - * it (there is no second receipt table — see the model docblock in - * `prisma/schema.prisma`). Everything that decides what a grant currently - * MEANS lives here, and nothing else in the tree is allowed to decide it. + * record, and since v1.36.x to add to it as well, and the row is also the + * durable record of the consent that created it (there is no second receipt + * table — see the model docblock in `prisma/schema.prisma`). Everything that + * decides what a grant currently MEANS lives here, and nothing else in the + * tree is allowed to decide it. * * Why one file rather than the rules at the call sites: two consumers read * these rows for different reasons — the acting-account resolver on every @@ -20,7 +21,7 @@ * Bearer perimeter that shouts at a mention is the right direction to be * wrong in.) * - * Three properties are the whole point, and each is a decision made HERE and + * Four properties are the whole point, and each is a decision made HERE and * nowhere else: * * * **Pending confers nothing.** An invitation is not access. A row with @@ -36,6 +37,11 @@ * the shape it does. Re-inviting the same person mints a NEW row; the * partial unique index in migration 0292 keeps at most one LIVE row per * pair while the history piles up underneath. + * * **The level never widens.** A row is READ or WRITE from the moment it is + * offered, and no transition here raises it. Widening a grant somebody has + * already accepted would change what they agreed to without asking them + * again, and the delegate's consent is half of what makes a write grant + * legitimate. {@link inviteGrant} carries the reasoning. * * Every write below is a CONDITIONAL update — the state the transition * requires is in the `where`, not in a read the caller performed a moment @@ -145,10 +151,8 @@ export function isGrantActive( * * Active first, then the level. A READ grant permits reads only; a WRITE grant * permits both, because write access to a record without read access describes - * nothing anyone asked for. v1 never writes a WRITE row (see - * {@link inviteGrant}), so in v1 this answers `false` for every write — which - * is the fail-closed arm the resolver wants, reached by the data rather than - * by a special case. + * nothing anyone asked for. The state outranks the level in both directions: a + * revoked WRITE grant permits nothing at all, and no level survives an expiry. */ export function grantAllows( grant: GrantLifecycle & { access: AccountGrantAccess }, @@ -166,6 +170,14 @@ export interface InviteGrantInput { grantorId: string; /** The account being invited to read it. */ granteeId: string; + /** + * What the invitation offers. Required, with no default in this module: a + * level nobody named is a level nobody chose, and the one place an omitted + * request field becomes READ is the invite schema + * (`src/lib/validations/account-sharing.ts`). Two defaults agreeing today is + * how they disagree later. + */ + access: AccountGrantAccess; /** Optional lapse date. Null = the grant runs until somebody ends it. */ expiresAt?: Date | null; } @@ -173,11 +185,16 @@ export interface InviteGrantInput { /** * Offer a grant. The delegate has access only once they accept it. * - * `access` is not a parameter. v1 grants READ and only READ (design §14): the - * column exists from day one so that delegated writes become enforcement work - * instead of migration work, and leaving the level out of the input is what - * makes "v1 never grants write" a property of this file rather than a promise - * in a comment. + * The level is fixed here and never moves. There is no widen, no PATCH, no + * upgrade endpoint, and the absence is the design rather than a gap: write + * access needs two consents, the owner's because it is their record and the + * delegate's because they are being asked to put something into somebody's + * health record under their own name. Acceptance is the single moment those + * two meet, and it happens once. Raising a live grant afterwards would carry + * one consent, and it would not be the delegate's. The way up is a new + * invitation at the level meant, accepted again — the partial unique index + * permits it as soon as the old row is ended, and the cost is one revoke and + * one accept. * * Self-grants are refused here rather than by a database CHECK — migration * 0292 carries the reason (the schema-driven wipe seeder plants a row with @@ -198,7 +215,7 @@ export async function inviteGrant( data: { grantorId: input.grantorId, granteeId: input.granteeId, - access: "READ", + access: input.access, invitedAt: now, expiresAt: input.expiresAt ?? null, }, diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts index 293291bcd..d2ae406cb 100644 --- a/src/lib/validations/account-sharing.ts +++ b/src/lib/validations/account-sharing.ts @@ -5,10 +5,6 @@ * and only two of them carry a body worth validating. What is NOT here is as * deliberate as what is: * - * * No `access` level. v1 grants READ and only READ, and the level is not a - * parameter anywhere in the stack — `inviteGrant` hardcodes it. A field - * here would be an input the domain module then ignores, which is the - * shape of a promise nobody kept. * * No `userId`, `grantorId` or `granteeId` on any body. Both sides of a * grant come from the authenticated session and the row itself. A body * field naming a party would be an authorization decision made by the @@ -43,6 +39,23 @@ export const inviteGrantSchema = z.object({ * remember to renew is worse than one that does not exist. */ expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + /** + * What the invitation offers, and the ONLY place the level is defaulted. + * + * `inviteGrant` takes the level as a required argument, so the domain module + * never guesses; this line is the single answer to "what does an omitted + * field mean", which keeps an older client's payload valid without a second + * copy of the same decision further down. A grant is READ or WRITE from the + * moment it is offered and stays that way: there is no endpoint that widens + * a live grant, because widening it would carry one consent and it would not + * be the delegate's. The way up is a new invitation, accepted again. + */ + access: z + .enum(["READ", "WRITE"]) + .default("READ") + .describe( + "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. Omitted means READ, so a client that predates the field keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", + ), }); export type InviteGrantBody = z.infer; diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index 908c240f4..a1afdb53f 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -32,6 +32,7 @@ import { GrantError, acceptGrant, findActiveGrant, + grantAllows, inviteGrant, renounceGrant, revokeGrant, @@ -98,6 +99,7 @@ async function acceptedGrant() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); const grant = await acceptGrant({ grantId: invited.id, @@ -120,6 +122,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); expect(invited.acceptedAt).toBeNull(); expect(invited.access).toBe("READ"); @@ -147,10 +150,42 @@ describe("account grant lifecycle", () => { expect(active?.id).toBe(invited.id); }); + it("stores the level the invitation offered, and confers it only once accepted", async () => { + // The column, not the argument: a write grant is the one thing in this + // feature that lets somebody put data into another person's record, so the + // proof that WRITE survives the round trip belongs against real Postgres + // rather than against a client fake. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "WRITE", + }); + const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }); + expect(stored.access).toBe("WRITE"); + // Offered is not accepted. Both consents or nothing. + expect(grantAllows(stored, "write")).toBe(false); + + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const live = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }); + expect(grantAllows(live, "write")).toBe(true); + expect(grantAllows(live, "read")).toBe(true); + }); + it("refuses an account sharing its record with itself", async () => { const solo = await makeUser("solo"); await expect( - inviteGrant({ grantorId: solo.id, granteeId: solo.id }), + inviteGrant({ + grantorId: solo.id, + granteeId: solo.id, + access: "READ", + }), ).rejects.toMatchObject({ code: "self_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(0); }); @@ -163,6 +198,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await expect( @@ -201,6 +237,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() - 1000), }); @@ -215,6 +252,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() + 60_000), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -331,13 +369,25 @@ describe("account grant lifecycle", () => { it("refuses a second live grant for the same pair", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); await expect( - inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }), ).rejects.toBeInstanceOf(GrantError); await expect( - inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }), ).rejects.toMatchObject({ code: "duplicate_live_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(1); }); @@ -352,6 +402,7 @@ describe("account grant lifecycle", () => { const second = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); expect(second.id).not.toBe(first.id); expect(second.acceptedAt).toBeNull(); @@ -384,10 +435,15 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await revokeGrant({ grantId: invited.id, grantorId: owner.id }); } - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); expect( await getPrismaClient().accountGrant.count({ where: { grantorId: owner.id, granteeId: delegate.id }, @@ -484,6 +540,7 @@ describe("account grant lifecycle paths", () => { const liveInvite = await inviteGrant({ grantorId: admin.id, granteeId: live.id, + access: "READ", }); const liveGrant = await acceptGrant({ grantId: liveInvite.id, @@ -492,6 +549,7 @@ describe("account grant lifecycle paths", () => { const droppedInvite = await inviteGrant({ grantorId: admin.id, granteeId: dropped.id, + access: "READ", }); await acceptGrant({ grantId: droppedInvite.id, granteeId: dropped.id }); const revoked = await revokeGrant({ diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index 37ce752e3..819afec3f 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -162,6 +162,7 @@ async function household() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); const grant = await acceptGrant({ grantId: invited.id, @@ -260,6 +261,7 @@ describe("acting-account resolver — the decision is never cached", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() + 400), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -275,7 +277,11 @@ describe("acting-account resolver — the decision is never cached", () => { it("refuses the moment the grant is only pending", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); const session = await signIn(delegate.id); await switchTo(session.id, owner.id); diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 422e87c25..a304b7ab1 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -98,6 +98,7 @@ async function household() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); const session = await signIn(delegate.id); @@ -223,6 +224,7 @@ describe("delegated reads coalesce to one row a day", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: second.id, + access: "READ", }); await acceptGrant({ grantId: invited.id, granteeId: second.id }); From d82e29737b9dd9e24ebfa8b087db6bdf64fd7779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:12:22 +0200 Subject: [PATCH 2/4] Say what the level means on both screens that decide it The invitation form gains the choice, and the option labels say what actually ships rather than the word "write": a delegate can add readings, results, observations, a medication and a marked dose, and can edit or delete nothing, including what they entered a moment ago. That gap between what "write access" sounds like and what it does is where a support case lives, so it is on the card rather than in a reply. The delegate reads the same thing before they accept. A pending write invitation states its level and one sentence about what accepting means, on the screen where they accept it, because acceptance is where the two consents meet and it happens once. Ending an access says what stays: the entries remain and the owner can remove any of them, and the activity view keeps saying who entered what for as long as the instance's audit window reaches. That number comes from the server, so an instance running ninety days says ninety, and a browser that has not been told says nothing rather than a made-up year. The browser journey now invites at the wider level and reads the posted body for it. That is the only check in the tree that can: the component convention is server-rendered only, so no test under src/ runs the form's submit path, and every one of them stays green with the control wired to a constant. --- e2e/account-sharing.spec.ts | 29 +++ messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/pl.json | 8 + .../__tests__/grant-access-level.test.tsx | 174 ++++++++++++++++++ .../settings/access/grant-invite-card.tsx | 106 ++++++++++- src/components/settings/access/grant-row.tsx | 14 ++ .../settings/access/grants-given-card.tsx | 58 +++++- .../settings/access/grants-received-card.tsx | 22 +++ src/lib/queries/use-account-grants.ts | 7 + 13 files changed, 451 insertions(+), 7 deletions(-) create mode 100644 src/components/settings/access/__tests__/grant-access-level.test.tsx diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index fdc54aac3..6a19aff1d 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -208,6 +208,21 @@ test.describe("account sharing", () => { const chosenDay = isoDayFromNow(30); await lapse.fill(chosenDay); + // The level, through the real control and onto the real wire. + // + // Same class of gap as the lapse date above, and the reason it is checked + // here rather than in a component test is worth stating: the project's + // component convention is SSR-only, so no test under `src/` can run this + // form's submit path at all. Wire the radio back to a constant and every + // one of those stays green. Only the posted body can tell the level a + // person picked from the level the card sends. + const writeOption = ownerPage.locator( + '[data-slot="grant-invite-access-option"][data-access="WRITE"]', + ); + await expect(writeOption).toBeVisible(); + await writeOption.click(); + await expect(writeOption).toHaveAttribute("data-selected", "true"); + const invitePost = ownerPage.waitForRequest( (req) => req.method() === "POST" && req.url().endsWith("/api/account/grants"), @@ -215,6 +230,7 @@ test.describe("account sharing", () => { await submit.click(); const posted = JSON.parse((await invitePost).postData() ?? "{}") as { expiresAt?: string | null; + access?: string; }; expect( posted.expiresAt, @@ -222,6 +238,10 @@ test.describe("account sharing", () => { ).toBeTruthy(); // End of the chosen day, not the midnight that starts it. expect(posted.expiresAt).toContain(chosenDay.slice(0, 8)); + expect( + posted.access, + "the invitation must carry the level the owner chose", + ).toBe("WRITE"); const row = ownerPage .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') @@ -229,6 +249,9 @@ test.describe("account sharing", () => { await expect(row).toBeVisible(); // Pending, not active. The state is the server's answer, rendered. await expect(row).toHaveAttribute("data-grant-state", "PENDING"); + // And the level came back from the database on the list read, which is the + // far end of the same wire: form → POST → row → GET → attribute. + await expect(row).toHaveAttribute("data-grant-access", "WRITE"); }); test("the delegate is offered nothing until they accept", async ({ @@ -241,6 +264,12 @@ test.describe("account sharing", () => { .first(); await expect(row).toBeVisible(); await expect(row).toHaveAttribute("data-grant-state", "PENDING"); + // The delegate's half of the consent: what they are being offered, and + // what accepting it will mean, on the screen where they accept it. + await expect(row).toHaveAttribute("data-grant-access", "WRITE"); + await expect( + row.locator('[data-slot="grant-write-consent"]'), + ).toBeVisible(); // No switcher yet: `accountAccess.canSwitch` is false while the only // grant is pending, and the menu binds that boolean. diff --git a/messages/de.json b/messages/de.json index 68ee1bc1b..cb3bb119c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Benutzername oder E-Mail", "identifierPlaceholder": "Der Name, mit dem sie sich anmeldet", "identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.", + "accessLegend": "Was die Person darf", + "accessReadLabel": "Darf lesen", + "accessReadCapability": "Sie kann alles lesen, was von dieser Akte geteilt wird. Hinzufügen oder ändern kann sie nichts.", + "accessWriteLabel": "Darf lesen und hinzufügen", + "accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, Allergien und andere Beobachtungen, ein neues Medikament und eine als genommen markierte Dosis. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat. Das kannst nur du.", "expiresLabel": "Zugriff endet am (optional)", "expiresHint": "Leer lassen für Zugriff, der gilt, bis ihn jemand beendet. Ein Datum wird bei jeder Anfrage geprüft, der Zugriff endet also am Ende dieses Tages von selbst.", "submit": "Einladung senden", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Zurückziehen", "revokeTitle": "Zugriff von {name} beenden?", "revokeBody": "Der Zugriff endet sofort, auch in jedem Browser, in dem deine Akte gerade offen ist. Was bereits gesehen wurde, lässt sich nicht zurückholen. Du kannst jederzeit erneut einladen.", + "revokeEntriesStay": "Was diese Person eingetragen hat, bleibt in deiner Akte, und du kannst alles davon selbst entfernen.", + "revokeAttribution": "Wer was eingetragen hat, bleibt {days} Tage lang in deiner Aktivitätsübersicht sichtbar.", "revokeConfirm": "Zugriff beenden" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "Die Liste konnte nicht geladen werden.", "accept": "Annehmen", "accepting": "Wird angenommen…", + "writeConsent": "Wenn du annimmst, wird das, was du hinzufügst, Teil der Akte von {name}. Ändern oder entfernen kannst du deine Einträge danach nicht mehr; das kann nur {name}.", "open": "Öffnen", "renounce": "Zurückgeben", "renounceTitle": "Akte von {name} zurückgeben?", diff --git a/messages/en.json b/messages/en.json index eaa97402e..cd9607368 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Username or e-mail", "identifierPlaceholder": "The name they sign in with", "identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.", + "accessLegend": "What they may do", + "accessReadLabel": "Can view", + "accessReadCapability": "They can read everything this record shares. They cannot add or change anything.", + "accessWriteLabel": "Can view and add", + "accessWriteCapability": "They can also add to the record: readings, lab results, allergies and other observations, a new medication, and marking a dose as taken. They cannot edit or delete anything, not even what they added. Only you can.", "expiresLabel": "Access ends on (optional)", "expiresHint": "Leave this empty for access that lasts until somebody ends it. A date here is checked on every request, so access stops on its own at the end of that day.", "submit": "Send invitation", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Withdraw", "revokeTitle": "End {name}'s access?", "revokeBody": "They lose access immediately, including any browser they have your record open in. What they have already seen cannot be taken back. You can invite them again at any time.", + "revokeEntriesStay": "Anything they entered stays in your record, and you can remove any of it yourself.", + "revokeAttribution": "Who entered what stays visible in your activity view for {days} days.", "revokeConfirm": "End access" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "The list could not be loaded.", "accept": "Accept", "accepting": "Accepting…", + "writeConsent": "If you accept, what you add becomes part of {name}'s record. You will not be able to change or remove entries afterwards; only {name} can.", "open": "Open", "renounce": "Hand back", "renounceTitle": "Hand {name}'s record back?", diff --git a/messages/es.json b/messages/es.json index 6b6cc1188..bf879ea85 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Usuario o correo electrónico", "identifierPlaceholder": "El nombre con el que inicia sesión", "identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.", + "accessLegend": "Qué puede hacer", + "accessReadLabel": "Puede ver", + "accessReadCapability": "Puede leer todo lo que este historial comparte. No puede añadir ni cambiar nada.", + "accessWriteLabel": "Puede ver y añadir", + "accessWriteCapability": "También puede añadir cosas al historial: mediciones, resultados de laboratorio, alergias y otras observaciones, un medicamento nuevo y una dosis marcada como tomada. No puede editar ni eliminar nada, tampoco lo que ha añadido. Solo tú puedes.", "expiresLabel": "El acceso termina el (opcional)", "expiresHint": "Déjalo vacío para un acceso que dure hasta que alguien lo termine. Una fecha aquí se comprueba en cada petición, así que el acceso se detiene solo al final de ese día.", "submit": "Enviar invitación", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Retirar", "revokeTitle": "¿Finalizar el acceso de {name}?", "revokeBody": "Pierde el acceso de inmediato, incluido cualquier navegador donde tenga tu historial abierto. Lo que ya ha visto no se puede recuperar. Puedes volver a invitarle cuando quieras.", + "revokeEntriesStay": "Lo que haya añadido se queda en tu historial, y puedes eliminarlo tú cuando quieras.", + "revokeAttribution": "Quién añadió qué sigue visible en tu vista de actividad durante {days} días.", "revokeConfirm": "Finalizar acceso" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "No se pudo cargar la lista.", "accept": "Aceptar", "accepting": "Aceptando…", + "writeConsent": "Si aceptas, lo que añadas pasa a formar parte del historial de {name}. Después no podrás cambiar ni eliminar entradas; solo {name} puede.", "open": "Abrir", "renounce": "Devolver", "renounceTitle": "¿Devolver el historial de {name}?", diff --git a/messages/fr.json b/messages/fr.json index 6c6f91a58..f62db527a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Nom d'utilisateur ou e-mail", "identifierPlaceholder": "Le nom avec lequel elle se connecte", "identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.", + "accessLegend": "Ce que cette personne peut faire", + "accessReadLabel": "Peut consulter", + "accessReadCapability": "Elle peut lire tout ce que ce dossier partage. Elle ne peut rien ajouter ni rien modifier.", + "accessWriteLabel": "Peut consulter et ajouter", + "accessWriteCapability": "Elle peut aussi ajouter des choses au dossier : des mesures, des résultats d'analyses, des allergies et d'autres observations, un nouveau médicament, et une prise marquée comme faite. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté. Vous êtes la seule personne à pouvoir le faire.", "expiresLabel": "L'accès prend fin le (facultatif)", "expiresHint": "Laissez ce champ vide pour un accès qui dure jusqu'à ce que quelqu'un y mette fin. Une date ici est vérifiée à chaque requête, l'accès s'arrête donc de lui-même à la fin de cette journée.", "submit": "Envoyer l'invitation", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Retirer", "revokeTitle": "Mettre fin à l'accès de {name} ?", "revokeBody": "L'accès cesse immédiatement, y compris dans tout navigateur où votre dossier est ouvert. Ce qui a déjà été vu ne peut pas être repris. Vous pouvez inviter de nouveau à tout moment.", + "revokeEntriesStay": "Ce que cette personne a saisi reste dans votre dossier, et vous pouvez en supprimer ce que vous voulez.", + "revokeAttribution": "Qui a saisi quoi reste visible dans votre vue d'activité pendant {days} jours.", "revokeConfirm": "Mettre fin à l'accès" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "La liste n'a pas pu être chargée.", "accept": "Accepter", "accepting": "Acceptation…", + "writeConsent": "Si vous acceptez, ce que vous ajoutez fait partie du dossier de {name}. Vous ne pourrez plus modifier ni supprimer vos entrées ensuite ; {name} est la seule personne à pouvoir le faire.", "open": "Ouvrir", "renounce": "Rendre", "renounceTitle": "Rendre le dossier de {name} ?", diff --git a/messages/it.json b/messages/it.json index d6e1847c4..524a67662 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Nome utente o e-mail", "identifierPlaceholder": "Il nome con cui accede", "identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.", + "accessLegend": "Che cosa può fare", + "accessReadLabel": "Può vedere", + "accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.", + "accessWriteLabel": "Può vedere e aggiungere", + "accessWriteCapability": "Può anche aggiungere qualcosa alla cartella: misurazioni, risultati di laboratorio, allergie e altre osservazioni, un nuovo farmaco e una dose segnata come presa. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto. Puoi farlo solo tu.", "expiresLabel": "L'accesso termina il (facoltativo)", "expiresHint": "Lascia vuoto per un accesso che dura finché qualcuno non lo termina. Una data qui viene controllata a ogni richiesta, quindi l'accesso si interrompe da solo alla fine di quel giorno.", "submit": "Invia invito", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Ritira", "revokeTitle": "Terminare l'accesso di {name}?", "revokeBody": "L'accesso finisce subito, anche in qualsiasi browser in cui la tua cartella è aperta. Ciò che è già stato visto non può essere ritirato. Puoi invitare di nuovo quando vuoi.", + "revokeEntriesStay": "Ciò che questa persona ha inserito resta nella tua cartella e sei tu a poterne rimuovere qualsiasi parte.", + "revokeAttribution": "Chi ha inserito che cosa resta visibile nella tua vista attività per {days} giorni.", "revokeConfirm": "Termina accesso" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "Non è stato possibile caricare l'elenco.", "accept": "Accetta", "accepting": "Accettazione…", + "writeConsent": "Se accetti, ciò che aggiungi entra a far parte della cartella di {name}. Dopo non potrai più modificare o rimuovere le voci; può farlo solo {name}.", "open": "Apri", "renounce": "Restituisci", "renounceTitle": "Restituire la cartella di {name}?", diff --git a/messages/pl.json b/messages/pl.json index 7ca42645b..b9d428617 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Nazwa użytkownika lub e-mail", "identifierPlaceholder": "Nazwa, którą się loguje", "identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.", + "accessLegend": "Co ta osoba może robić", + "accessReadLabel": "Może przeglądać", + "accessReadCapability": "Może czytać wszystko, co ta karta udostępnia. Nie może niczego dodać ani zmienić.", + "accessWriteLabel": "Może przeglądać i dodawać", + "accessWriteCapability": "Może też dodawać do karty: pomiary, wyniki badań, alergie i inne obserwacje, nowy lek oraz oznaczenie dawki jako przyjętej. Nie może niczego edytować ani usuwać, także własnych wpisów. Możesz to zrobić tylko ty.", "expiresLabel": "Dostęp kończy się (opcjonalnie)", "expiresHint": "Zostaw puste, aby dostęp trwał, dopóki ktoś go nie zakończy. Data jest sprawdzana przy każdym żądaniu, więc dostęp kończy się sam z końcem tego dnia.", "submit": "Wyślij zaproszenie", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Wycofaj", "revokeTitle": "Zakończyć dostęp dla {name}?", "revokeBody": "Dostęp kończy się natychmiast, także w każdej przeglądarce, w której twoja karta jest otwarta. Tego, co już zostało zobaczone, nie da się cofnąć. Możesz zaprosić ponownie w dowolnej chwili.", + "revokeEntriesStay": "To, co ta osoba wpisała, zostaje w twojej karcie i możesz to samodzielnie usunąć.", + "revokeAttribution": "Kto co wpisał, pozostaje widoczne w widoku aktywności przez {days} dni.", "revokeConfirm": "Zakończ dostęp" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "Nie udało się wczytać listy.", "accept": "Akceptuj", "accepting": "Akceptowanie…", + "writeConsent": "Jeśli zaakceptujesz, to, co dodasz, stanie się częścią karty użytkownika {name}. Później nie będziesz mieć możliwości zmiany ani usunięcia wpisów; może to zrobić tylko {name}.", "open": "Otwórz", "renounce": "Zwróć", "renounceTitle": "Zwrócić kartę użytkownika {name}?", diff --git a/src/components/settings/access/__tests__/grant-access-level.test.tsx b/src/components/settings/access/__tests__/grant-access-level.test.tsx new file mode 100644 index 000000000..1a907ba97 --- /dev/null +++ b/src/components/settings/access/__tests__/grant-access-level.test.tsx @@ -0,0 +1,174 @@ +/** + * The access level, on the three screens that decide it. + * + * What an SSR render can prove, and what it cannot, stated up front because the + * distinction is the reason this file is shaped the way it is. It can prove + * that a control exists, that a sentence is on the page beside it, and that a + * row states its level before the button that accepts it. It CANNOT prove that + * the chosen level reaches the server: there is no click here, so the submit + * path never runs, and every assertion below stays green with the form wired + * back to a constant. That half is the browser journey's + * (`e2e/account-sharing.spec.ts`, which reads the posted body), and the + * database half is `src/app/api/account/grants/__tests__/`. + * + * The invitation copy is asserted by its promise rather than by its wording: + * "cannot edit or delete" is the sentence somebody's expectation is set by, and + * a rewrite that quietly drops it should fail here. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants"; + +const grantsRef: { value: GrantList } = { value: { given: [], received: [] } }; + +vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useInviteGrant: () => ({ mutate: vi.fn(), isPending: false }), + useAccountGrants: () => ({ + data: grantsRef.value, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useRecordActivity: () => ({ + data: undefined, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useAcceptGrant: () => ({ mutate: vi.fn(), isPending: false }), + useRenounceGrant: () => ({ mutate: vi.fn(), isPending: false }), + useRevokeGrant: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { GrantInviteCard } from "../grant-invite-card"; +import { GrantsReceivedCard } from "../grants-received-card"; +import { revokeBody } from "../grants-given-card"; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +const BASE: GrantRow = { + id: "g1", + account: { id: "acct-1", username: "housemate", displayName: "Jo" }, + access: "READ", + state: "PENDING", + invitedAt: "2026-01-02T10:00:00.000Z", + acceptedAt: null, + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + revokedBy: null, +}; + +describe("offering a level", () => { + it("offers both levels and preselects the narrower one", () => { + const html = render(); + expect(html).toContain('data-slot="grant-invite-access"'); + expect(html).toContain('data-access="READ"'); + expect(html).toContain('data-access="WRITE"'); + // The narrow level is the one somebody chooses their way out of. + expect(html).toContain('data-access="READ" data-selected="true"'); + expect(html).toContain('data-access="WRITE" data-selected="false"'); + }); + + it("says what the wider level actually does, and what it does not", () => { + const html = render(); + // The thing a person reading "write access" gets wrong. + expect(html).toContain("cannot edit or delete anything"); + expect(html).toContain("marking a dose as taken"); + // And the narrow option still says it is narrow. + expect(html).toContain("They cannot add or change anything."); + }); +}); + +describe("accepting a level", () => { + it("states the level and what accepting it means before the button", () => { + grantsRef.value = { + given: [], + received: [{ ...BASE, access: "WRITE" }], + }; + const html = render(); + + expect(html).toContain('data-grant-access="WRITE"'); + expect(html).toContain("Can read and add entries"); + expect(html).toContain('data-slot="grant-write-consent"'); + // In document order the consent sits ahead of the control it qualifies. + expect(html.indexOf('data-slot="grant-write-consent"')).toBeLessThan( + html.indexOf('data-slot="grant-accept"'), + ); + }); + + it("does not ask a read invitation for a write consent", () => { + grantsRef.value = { given: [], received: [{ ...BASE, access: "READ" }] }; + const html = render(); + expect(html).toContain('data-grant-access="READ"'); + expect(html).not.toContain('data-slot="grant-write-consent"'); + }); + + it("stops saying it once the invitation has been answered", () => { + // A sentence about a decision already taken is nagging, not consent. + grantsRef.value = { + given: [], + received: [ + { + ...BASE, + access: "WRITE", + state: "ACTIVE", + acceptedAt: "2026-01-02T11:00:00.000Z", + }, + ], + }; + const html = render(); + expect(html).not.toContain('data-slot="grant-write-consent"'); + }); +}); + +describe("ending an access, and what it says about what was entered", () => { + const t = (key: string, params?: Record): string => + params ? `${key}(${JSON.stringify(params)})` : key; + + it("says nothing about entries for a grant that could not make any", () => { + const body = revokeBody({ + t, + access: "READ", + name: "Jo", + retentionDays: 90, + }); + expect(body).toContain("recordSharing.given.revokeBody"); + expect(body).not.toContain("revokeEntriesStay"); + expect(body).not.toContain("revokeAttribution"); + }); + + it("tells a write owner that the entries stay and stay attributed", () => { + const body = revokeBody({ + t, + access: "WRITE", + name: "Jo", + retentionDays: 90, + }); + expect(body).toContain("recordSharing.given.revokeEntriesStay"); + expect(body).toContain('revokeAttribution({"days":90})'); + }); + + it("omits the window rather than inventing one", () => { + // The number is the operator's setting. A compiled-in 365 here would be + // exactly the invented value the sentence exists to avoid. + const body = revokeBody({ t, access: "WRITE", name: "Jo" }); + expect(body).toContain("recordSharing.given.revokeEntriesStay"); + expect(body).not.toContain("revokeAttribution"); + }); +}); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index e54573446..ee9043738 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -13,12 +13,21 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInviteGrant } from "@/lib/queries/use-account-grants"; /** - * v1.36.0 — offering somebody read access to this record. + * v1.36.0 — offering somebody access to this record. * - * One field, because there is one decision: who. The access level is not - * offered — v1 grants read and only read, and a control whose only setting is - * its default is a promise about a future release rather than a choice about - * this one. + * Two decisions: who, and what they may do. The level is a choice at the + * invitation and only there — a grant is READ or WRITE from the moment it is + * offered, nothing widens it later, and the way to a wider one is a fresh + * invitation the other person accepts again. So this form is the one screen + * where the owner's half of that consent is given, and the option labels have + * to be worth reading. + * + * The capability sentences say what actually ships rather than the word + * "write", which people read as larger than it is. A delegate can ADD: + * readings, results, observations, a medication, a dose marked as taken. They + * cannot edit or delete anything, including what they added ten seconds ago. + * A person who learns that from a support reply rather than from this card was + * not told. * * The invitation confers nothing until the other person accepts it. The card * says so above the field rather than after the submit, because that is the @@ -35,6 +44,9 @@ export function GrantInviteCard() { const invite = useInviteGrant(); const [identifier, setIdentifier] = useState(""); const [expiresOn, setExpiresOn] = useState(""); + // READ preselected. The narrower level is the one somebody should have to + // choose their way out of, not into. + const [access, setAccess] = useState("READ"); const [error, setError] = useState(null); const [invited, setInvited] = useState(null); @@ -45,11 +57,12 @@ export function GrantInviteCard() { setError(null); setInvited(null); invite.mutate( - { identifier: value, expiresAt: endOfDayIso(expiresOn) }, + { identifier: value, access, expiresAt: endOfDayIso(expiresOn) }, { onSuccess: (grant) => { setIdentifier(""); setExpiresOn(""); + setAccess("READ"); setInvited(grant.account.username); }, onError: (err) => setError(t(inviteErrorKey(err))), @@ -82,6 +95,30 @@ export function GrantInviteCard() { {t("recordSharing.invite.identifierHint")}

+
+ {/* The `Label` primitive is a `
{actions &&
{actions}
} diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index 756447179..006da72b3 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { accountLabel } from "@/lib/sharing/account-access-view"; import { useAccountGrants, + useRecordActivity, useRevokeGrant, type GrantRow, } from "@/lib/queries/use-account-grants"; @@ -29,11 +30,21 @@ import { * health record to somebody. The confirm dialog exists only because the click * target sits in a list and a mis-tap should not end an access silently; it * names the person and the act. + * + * What the dialog SAYS depends on what the grant could do: see + * {@link revokeBody}. */ + export function GrantsGivenCard() { const { t } = useTranslations(); const { data, isLoading, isError, refetch } = useAccountGrants(); const revoke = useRevokeGrant(); + // Read for one number: how long the activity view can still say who entered + // what. The activity card on this same page holds the query, so this is the + // cached answer rather than a second request — and when the server has not + // answered yet, the sentence that would need the number is left out instead + // of being written around a guessed 365. + const activity = useRecordActivity(); const given = data?.given ?? []; @@ -95,7 +106,12 @@ export function GrantsGivenCard() { : t("recordSharing.given.revoke") } title={t("recordSharing.given.revokeTitle", { name })} - body={t("recordSharing.given.revokeBody", { name })} + body={revokeBody({ + t, + access: grant.access, + name, + retentionDays: activity.data?.retentionDays, + })} confirmLabel={t("recordSharing.given.revokeConfirm")} pending={revoke.isPending} onConfirm={() => revoke.mutate(grant.id)} @@ -103,3 +119,43 @@ export function GrantsGivenCard() { ); } } + +/** + * What ending this particular access does, as one paragraph. + * + * Exported and pure because the dialog it feeds only exists after a click, and + * an SSR render cannot see inside it — the same reason `endOfDayIso` is + * exported from the invite card. The composition is the thing worth pinning: + * which sentences appear, and which one is omitted when its number is unknown. + * + * Two extra sentences for a grant that could add entries, and only for one: a + * read grant never put anything in the record, so telling its owner that + * "anything they entered stays" would describe something that cannot have + * happened. The last sentence is the honest bound on attribution — the activity + * view answers "who entered what" for as long as the audit window reaches, and + * that window is the operator's setting. It is said with the number the server + * resolved, or not said at all. + */ +export function revokeBody({ + t, + access, + name, + retentionDays, +}: { + t: (key: string, params?: Record) => string; + access: GrantRow["access"]; + name: string; + /** Undefined until the server has said what the window is. */ + retentionDays?: number; +}): string { + const lines = [t("recordSharing.given.revokeBody", { name })]; + if (access === "WRITE") { + lines.push(t("recordSharing.given.revokeEntriesStay")); + if (retentionDays !== undefined) { + lines.push( + t("recordSharing.given.revokeAttribution", { days: retentionDays }), + ); + } + } + return lines.join(" "); +} diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx index 73ad296fe..4ff35cf68 100644 --- a/src/components/settings/access/grants-received-card.tsx +++ b/src/components/settings/access/grants-received-card.tsx @@ -78,6 +78,7 @@ export function GrantsReceivedCard() { key={grant.id} grant={grant} side="received" + notice={renderConsent(grant)} actions={renderActions(grant)} /> ))} @@ -87,6 +88,27 @@ export function GrantsReceivedCard() { ); + /** + * The delegate's half of the consent, on the screen where they give it. + * + * A write invitation asks for something a read invitation does not: that the + * person put entries into somebody else's health record under their own + * name, permanently, with no way to take one back. That is worth one + * sentence before the button rather than a sentence after the first entry. + * Shown only while the invitation is unanswered — once accepted, the level + * is the row's own line and this would be nagging. + */ + function renderConsent(grant: GrantRow) { + if (grant.state !== "PENDING" || grant.access !== "WRITE") return null; + return ( +

+ {t("recordSharing.received.writeConsent", { + name: accountLabel(grant.account), + })} +

+ ); + } + function renderActions(grant: GrantRow) { if (grant.state === "PENDING") { return ( diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts index 8ea22df03..453bcca46 100644 --- a/src/lib/queries/use-account-grants.ts +++ b/src/lib/queries/use-account-grants.ts @@ -120,6 +120,13 @@ export interface InviteInput { identifier: string; /** ISO-8601 with offset, or null for a grant that runs until somebody ends it. */ expiresAt: string | null; + /** + * What the invitation offers. Required rather than optional even though the + * server defaults an omitted field to READ: a caller that does not name the + * level has not decided it, and this is the one moment the level can be + * decided at all. + */ + access: "READ" | "WRITE"; } export function useInviteGrant() { From ca3a654e47c99fe25153ca9449946d772622f700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:14:06 +0200 Subject: [PATCH 3/4] Say what a grant is now that it can carry two levels The module header still described a permission to read, and the grantee field still said "invited to read it". Both are half the sentence once the level is a choice. --- src/lib/sharing/grants.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index c3765a3be..7c984ab91 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -1,12 +1,12 @@ /** * v1.36.0 — the account-grant state machine. One file, one set of rules. * - * A grant is one account's standing permission to read another account's - * record, and since v1.36.x to add to it as well, and the row is also the - * durable record of the consent that created it (there is no second receipt - * table — see the model docblock in `prisma/schema.prisma`). Everything that - * decides what a grant currently MEANS lives here, and nothing else in the - * tree is allowed to decide it. + * A grant is one account's standing permission to open another account's + * record — to read it, and where the grant says so to add to it. The row is + * also the durable record of the consent that created it (there is no second + * receipt table — see the model docblock in `prisma/schema.prisma`). + * Everything that decides what a grant currently MEANS lives here, and nothing + * else in the tree is allowed to decide it. * * Why one file rather than the rules at the call sites: two consumers read * these rows for different reasons — the acting-account resolver on every @@ -168,7 +168,7 @@ export function grantAllows( export interface InviteGrantInput { /** The account whose record is being shared. */ grantorId: string; - /** The account being invited to read it. */ + /** The account being invited to it. */ granteeId: string; /** * What the invitation offers. Required, with no default in this module: a From fe142bbd3af1275d70a71a7053f298e424116a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:20:26 +0200 Subject: [PATCH 4/4] Correct the entry type's account of what a level is The payload type still said the level is read throughout and `canWrite` false. Both were true of the shipped release and neither survives the invitation gaining a choice. Also says what `canWrite` does not mean, since the word invites a larger reading than the thing it grants. --- src/lib/sharing/account-access-view.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 264e3693c..de0d06d0d 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -20,9 +20,13 @@ export interface AccountAccessEntry { accountId: string; username: string; displayName: string | null; - /** The grant's level. `"read"` throughout v1. */ + /** The grant's level, as the owner offered it and the caller accepted it. */ access: "read" | "write"; - /** May this caller change that record. Resolved server-side; `false` in v1. */ + /** + * May this caller ADD to that record. Resolved server-side. It never means + * edit or delete: those stay with the owner at both levels, and no field + * here offers them. + */ canWrite: boolean; }