From e33700c7a9103807aee52c17e064dc60c3e71518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 16:07:25 +0200 Subject: [PATCH] feat(sharing): name a record's owner in full to whoever they share with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switcher and the banner named a shared record's owner by their greeting name — the value they typed at onboarding for "how should HealthLog address you". When an owner has set a full name, that is what a delegate should see: entering somebody's record, you read whose it is by who they are. - the account-access resolver publishes the owner's `fullName` alongside the greeting name and the username - `accountLabel` prefers fullName, then displayName, then username. Optional on the argument so the grant-view and actor shapes that never carried it keep the old two-field behaviour untouched - the closed presentation schema carries `fullName` and its parity matcher compares it, so the two published views of the active record cannot name the same owner differently - the OpenAPI contract regenerates with the additive field This deliberately discloses the owner's full name to every delegate, read-only included — it was patient-identity data behind a MANAGE-gated export until now. That is the intended behaviour, documented at `accountLabel` so it is not mistaken for a leak and tightened back. The resolver, the schema parity, the label fallback and a read-only delegate's banner are each pinned by a test. --- docs/api/openapi.yaml | 10 +++ e2e/account-sharing.spec.ts | 18 ++++- e2e/delegated-writes.spec.ts | 5 +- e2e/setup/global-setup.ts | 14 +++- e2e/setup/test-helpers.ts | 1 + .../take-all-due-affordance.test.tsx | 1 + .../delegated-write-affordances.test.tsx | 1 + .../__tests__/chart-overlay-controls.test.tsx | 1 + .../auth-shell-shared-record-doors.test.tsx | 2 + .../__tests__/shared-record-chrome.test.tsx | 20 ++++++ .../managed-profile-affordances.test.tsx | 1 + .../use-auth-account-access-state.test.ts | 1 + .../__tests__/use-record-capabilities.test.ts | 1 + .../__tests__/coach-launch-context.test.tsx | 1 + .../__tests__/account-access-schema.test.ts | 6 ++ .../sharing/__tests__/account-access.test.ts | 26 ++++++++ .../sharing/__tests__/account-label.test.ts | 65 +++++++++++++++++++ src/lib/sharing/account-access-schema.ts | 5 ++ src/lib/sharing/account-access-view.ts | 31 +++++++-- src/lib/sharing/account-access.ts | 5 ++ 20 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/lib/sharing/__tests__/account-label.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 33fb729bd..b18ff18e1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -38831,6 +38831,10 @@ components: anyOf: - type: string - type: "null" + fullName: + anyOf: + - type: string + - type: "null" access: type: string enum: @@ -38868,6 +38872,7 @@ components: - accountId - username - displayName + - fullName - access - level - recordKind @@ -38888,6 +38893,10 @@ components: anyOf: - type: string - type: "null" + fullName: + anyOf: + - type: string + - type: "null" access: type: string enum: @@ -38925,6 +38934,7 @@ components: - accountId - username - displayName + - fullName - access - level - recordKind diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index f721792c1..618ff1ee5 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -69,6 +69,7 @@ import { DELEGATE_STORAGE_STATE_PATH, E2E_DELEGATE_MARKER_KG, E2E_OWNER, + E2E_OWNER_FULL_NAME, E2E_USER, OWNER_STORAGE_STATE_PATH, } from "./setup/test-helpers"; @@ -312,7 +313,17 @@ test.describe("account sharing", () => { const entry = page.locator('[data-slot="account-switcher-entry"]'); await expect(entry).toHaveCount(1); - await expect(entry).toContainText(E2E_OWNER.username); + // v1.37.2 — the owner set a full name, so a delegate sees them by it + // rather than by the greeting name or the login handle. This is the + // record-owner's full name crossing to a read-only delegate, which is the + // deliberate disclosure the maintainer decided on. The username stays on + // the row as an attribute, which is the positive control for the label + // assertion — without it, a renamed slot would make this read green. + await expect(entry).toContainText(E2E_OWNER_FULL_NAME); + await expect(entry).toHaveAttribute( + "data-account-username", + E2E_OWNER.username, + ); // Two lines: the name and the kind of record. The access level used to // take a third and was truncated more often than it was read; it is what // the banner says once you are inside. The row still CARRIES the level as @@ -351,10 +362,11 @@ test.describe("account sharing", () => { // Wait for CONTENT, and for content the assertion is not about: the // banner naming the owner. A skeleton does not satisfy it, and it says - // nothing about the marker either way. + // nothing about the marker either way. The owner set a full name, so the + // banner names them by it (v1.37.2) — the same label the switcher used. const banner = page.locator('[data-slot="shared-record-banner"]'); await expect(banner).toBeVisible(); - await expect(banner).toContainText(E2E_OWNER.username); + await expect(banner).toContainText(E2E_OWNER_FULL_NAME); // The document showing the banner has never shown the delegate's own // number. The latch was armed by that document's own init script, so this diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 42d7a972c..2957dc2d7 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -39,7 +39,7 @@ import type { BrowserContext, Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { DELEGATE_STORAGE_STATE_PATH, - E2E_OWNER, + E2E_OWNER_FULL_NAME, E2E_USER, OWNER_STORAGE_STATE_PATH, } from "./setup/test-helpers"; @@ -149,7 +149,8 @@ test.describe("delegated writes", () => { const banner = page.locator('[data-slot="shared-record-banner"]'); await expect(banner).toBeVisible(); - await expect(banner).toContainText(E2E_OWNER.username); + // v1.37.2 — the owner set a full name, so the banner names them by it. + await expect(banner).toContainText(E2E_OWNER_FULL_NAME); // The banner drops its read-only clause on its own once the level says so. await expect(banner).not.toContainText("read it, not change it"); }); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 981a55c33..3619bfaa7 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -65,6 +65,12 @@ export const E2E_OWNER = { role: "USER", } as const; +/** + * v1.37.2 — the owner carries a full name, so the switcher journey can assert + * that a delegate sees it. Generic on purpose: fixtures hold no real name. + */ +export const E2E_OWNER_FULL_NAME = "Test Full Name"; + /** * v1.37.0 — the account that creates and administers managed profiles. * @@ -592,20 +598,22 @@ export default async function globalSetup(config: FullConfig): Promise { `INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, - onboarding_completed_at, onboarding_tour_completed) - VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + onboarding_completed_at, onboarding_tour_completed, full_name) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, - onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + full_name = EXCLUDED.full_name`, [ cuid(), E2E_OWNER.username, E2E_OWNER.email, await hashPassword(E2E_OWNER.password), now, + E2E_OWNER_FULL_NAME, ], ); diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 9b075c908..9fd153d33 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -9,6 +9,7 @@ export { E2E_USER, E2E_OWNER, + E2E_OWNER_FULL_NAME, E2E_GUARDIAN, E2E_SCOPE_DELEGATE, E2E_SCOPE_RECORDS, diff --git a/src/app/medications/__tests__/take-all-due-affordance.test.tsx b/src/app/medications/__tests__/take-all-due-affordance.test.tsx index 1454d53b5..0a9407b01 100644 --- a/src/app/medications/__tests__/take-all-due-affordance.test.tsx +++ b/src/app/medications/__tests__/take-all-due-affordance.test.tsx @@ -53,6 +53,7 @@ const OWNER = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "read" as const, level: "read" as const, sections: null, diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index a7a5ec68b..c8a14fd45 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -36,6 +36,7 @@ const OWNER = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "read" as const, level: "read" as const, sections: null, diff --git a/src/components/charts/__tests__/chart-overlay-controls.test.tsx b/src/components/charts/__tests__/chart-overlay-controls.test.tsx index 8eca455f6..62d6d7af9 100644 --- a/src/components/charts/__tests__/chart-overlay-controls.test.tsx +++ b/src/components/charts/__tests__/chart-overlay-controls.test.tsx @@ -7,6 +7,7 @@ const OWNER = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "write" as const, level: "write" as const, sections: null, diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx index ba87832d5..7757c8e32 100644 --- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx +++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx @@ -132,6 +132,7 @@ const OWNER_READ: AccountAccessEntry = { accountId: "acct-owner", username: "grandma", displayName: "Margarethe", + fullName: null, access: "read" as const, level: "read" as const, sections: null, @@ -148,6 +149,7 @@ const MANAGED_GUARDIAN: AccountAccessEntry = { accountId: "managed-record", username: "managed-record", displayName: "Managed profile", + fullName: null, access: "write" as const, level: "manage" as const, sections: null, diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx index 53a2049f2..cfed8bb74 100644 --- a/src/components/layout/__tests__/shared-record-chrome.test.tsx +++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx @@ -56,6 +56,7 @@ const OWNER = { accountId: "acct-owner", username: "grandma", displayName: "Margarethe", + fullName: null, access: "read" as const, level: "read" as const, sections: null, @@ -108,6 +109,25 @@ describe("", () => { expect(html).toContain("grandma"); }); + it("names the owner in full when they have a full name, to a read-only delegate", () => { + // v1.37.2 — the banner is the account-access view that renders server-side, + // and this OWNER is READ (`canWrite: false`). The full name leads over the + // greeting name: a delegate about to log a reading sees whose record it is + // as who they are, not by a nickname. A deliberate disclosure to everyone + // shared with, read-only included (maintainer decision 2026-08-08). + const named = { + ...OWNER, + fullName: "Test Full Name", + displayName: "Margarethe", + }; + const html = render( + { accounts: [named], active: named, canSwitch: true }, + , + ); + expect(html).toContain('data-slot="shared-record-banner"'); + expect(html).toContain("Test Full Name"); + }); + it("says the access is read-only, and carries the way out", () => { const html = render( { accounts: [OWNER], active: OWNER, canSwitch: true }, diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index f75482bf7..58c07f1c3 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -213,6 +213,7 @@ function entry(partial: Partial = {}): AccountAccessEntry { accountId: "p1", username: "managed-abc", displayName: "Managed record", + fullName: null, access: "write", level: "manage", recordKind: "managed", diff --git a/src/hooks/__tests__/use-auth-account-access-state.test.ts b/src/hooks/__tests__/use-auth-account-access-state.test.ts index b9ec29a73..cabb5bad1 100644 --- a/src/hooks/__tests__/use-auth-account-access-state.test.ts +++ b/src/hooks/__tests__/use-auth-account-access-state.test.ts @@ -31,6 +31,7 @@ const canonicalEntry = { accountId: "record-a", username: "record-a", displayName: "Record A", + fullName: null, access: "read", level: "read", recordKind: "shared", diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index 256e3c487..4a7609b06 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -27,6 +27,7 @@ const READ_ONLY: AccountAccessEntry = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "read", level: "read", sections: null, diff --git a/src/lib/insights/__tests__/coach-launch-context.test.tsx b/src/lib/insights/__tests__/coach-launch-context.test.tsx index b294d8ad7..9e94ce003 100644 --- a/src/lib/insights/__tests__/coach-launch-context.test.tsx +++ b/src/lib/insights/__tests__/coach-launch-context.test.tsx @@ -29,6 +29,7 @@ const OWNER = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "write" as const, level: "write" as const, sections: null, diff --git a/src/lib/sharing/__tests__/account-access-schema.test.ts b/src/lib/sharing/__tests__/account-access-schema.test.ts index 69db679b9..a702767ad 100644 --- a/src/lib/sharing/__tests__/account-access-schema.test.ts +++ b/src/lib/sharing/__tests__/account-access-schema.test.ts @@ -6,6 +6,7 @@ const sharedEntry = { accountId: "record-a", username: "record-a", displayName: "Record A", + fullName: "Test Full Name", access: "read", level: "read", recordKind: "shared", @@ -73,6 +74,11 @@ describe("parseAccountAccess", () => { ], ["username", { username: "different-record" }, {}], ["display name", { displayName: "Different record" }, {}], + // v1.37.2 — the owner's full name is part of the entry now, so the two + // published views of the active record must agree on it like every other + // field. A parity matcher blind to it would let the banner and the + // switcher name the same owner differently. + ["full name", { fullName: "Different name" }, {}], ])( "fails closed when active disagrees with its canonical entry on %s", (_field, activeOverrides, blockOverrides) => { diff --git a/src/lib/sharing/__tests__/account-access.test.ts b/src/lib/sharing/__tests__/account-access.test.ts index a1b89c734..3fc48b80a 100644 --- a/src/lib/sharing/__tests__/account-access.test.ts +++ b/src/lib/sharing/__tests__/account-access.test.ts @@ -31,6 +31,7 @@ function activeGrant({ id: "record-owner", username: "record-owner", displayName: "Record owner", + fullName: "Test Full Name", managedProfileAt, }, }; @@ -93,6 +94,31 @@ describe("resolveAccountAccess", () => { expect(access.recordKind).toBe("self"); }); + it("carries the owner's full name to a read-only delegate", async () => { + // v1.37.2 — the record owner's full name crosses to whoever they share + // with, read-only included (maintainer decision 2026-08-08). It is the + // resolver that lets it cross, so it is the resolver that proves it: a + // READ grant, and the delegate's resolved entry still names the owner in + // full. If a future change drops `fullName` from the grantor `select`, + // this fails rather than silently reverting the disclosure to a nickname. + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + activeGrant({ access: "READ" }), + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: {}, + }); + + expect(access.accounts[0]).toEqual( + expect.objectContaining({ + access: "read", + canWrite: false, + fullName: "Test Full Name", + }), + ); + }); + it("keeps whole-record legacy payloads decodable and scoped payloads closed", () => { for (const fixture of LEGACY_ACCOUNT_PAYLOADS) { const decoded = decodeLegacyWholeRecordAccess(fixture); diff --git a/src/lib/sharing/__tests__/account-label.test.ts b/src/lib/sharing/__tests__/account-label.test.ts new file mode 100644 index 000000000..485df1d87 --- /dev/null +++ b/src/lib/sharing/__tests__/account-label.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { accountLabel } from "../account-access-view"; + +/** + * `accountLabel` is the one place the banner, the switcher and the sharing + * panel resolve what to call a person, so the fallback order lives here and + * is pinned here. + * + * v1.37.2 — the record owner's full name leads, ahead of the greeting name. + * It is patient-identity data shown to everyone they share with, read-only + * included (maintainer decision 2026-08-08); the switcher and the banner pass + * the account-access entry that carries it, while the grant-view and actor + * shapes that never had it omit the argument and keep the old behaviour. + */ +describe("accountLabel", () => { + it("prefers the full name when it is set", () => { + expect( + accountLabel({ + fullName: "Test Full Name", + displayName: "Testy", + username: "test-user", + }), + ).toBe("Test Full Name"); + }); + + it("falls back to the display name when there is no full name", () => { + expect( + accountLabel({ + fullName: null, + displayName: "Testy", + username: "test-user", + }), + ).toBe("Testy"); + // A blank full name is not a name — trimmed to empty, it falls through. + expect( + accountLabel({ + fullName: " ", + displayName: "Testy", + username: "test-user", + }), + ).toBe("Testy"); + }); + + it("falls back to the username when neither name is set", () => { + expect( + accountLabel({ + fullName: null, + displayName: null, + username: "test-user", + }), + ).toBe("test-user"); + }); + + it("keeps the old two-field behaviour when no full name is passed", () => { + // The grant-view and actor shapes never carried a full name; omitting the + // argument must read exactly as it did before the field existed. + expect(accountLabel({ displayName: "Testy", username: "test-user" })).toBe( + "Testy", + ); + expect(accountLabel({ displayName: null, username: "test-user" })).toBe( + "test-user", + ); + }); +}); diff --git a/src/lib/sharing/account-access-schema.ts b/src/lib/sharing/account-access-schema.ts index 72faf24e2..779afc84b 100644 --- a/src/lib/sharing/account-access-schema.ts +++ b/src/lib/sharing/account-access-schema.ts @@ -13,6 +13,10 @@ export const accountAccessEntrySchema = z accountId: z.string().min(1), username: z.string().min(1), displayName: z.string().nullable(), + // v1.37.2 — the record owner's full name, shown in front of a delegate on + // the switcher + banner. A deliberate disclosure to everyone they share + // with; see `accountLabel`. + fullName: z.string().nullable(), access: z.enum(["read", "write"]), level: accountAccessLevelSchema, recordKind: accountRecordKindSchema, @@ -80,6 +84,7 @@ function matchesCanonicalAccountAccessEntry( active.accountId === canonical.accountId && active.username === canonical.username && active.displayName === canonical.displayName && + active.fullName === canonical.fullName && active.access === canonical.access && active.level === canonical.level && active.recordKind === canonical.recordKind && diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 39b020583..23c360bf9 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -50,6 +50,14 @@ export interface AccountAccessEntry { accountId: string; username: string; displayName: string | null; + /** + * v1.37.2 — the record owner's full name, when they have set one. Shown in + * front of a delegate on the switcher and the banner, ahead of the greeting + * name, so the person whose record they are entering reads as who they are + * rather than by a nickname. See {@link accountLabel} for the deliberate + * disclosure this carries; it is a maintainer decision, not an oversight. + */ + fullName: string | null; /** * The grant's level, as the owner offered it and the caller accepted it. * @@ -116,16 +124,29 @@ export interface AccountAccess { /** * The name to put in front of a person, given what the payload published. * - * `displayName` when the account set one, the username otherwise. This is a - * presentation fallback and not a derivation of anything the server decided — - * both values are published, and which of the two reads better is the client's - * business. Kept here so the banner, the switcher and the sharing panel cannot - * disagree about what to call somebody. + * `fullName` when it is set, `displayName` next, the username last. All three + * are published; which reads best is the client's business, not a derivation + * of anything the server decided. Kept here so the banner, the switcher and + * the sharing panel cannot disagree about what to call somebody. + * + * `fullName` is optional on the argument on purpose: only the account-access + * entry carries it, so the switcher and the banner prefer it while the + * grant-view and actor shapes that never had it keep falling back to the + * greeting name exactly as before. + * + * v1.37.2 — the record owner's full name is shown to anyone they share with, + * read-only included; maintainer decision 2026-08-08. It is patient-identity + * data that until now stayed behind a MANAGE-gated export, so a future reader + * should not "tighten" this back as a leak: it is the intended behaviour, not + * an accident of the label falling through to it. */ export function accountLabel(entry: { displayName: string | null; username: string; + fullName?: string | null; }): string { + const full = entry.fullName?.trim(); + if (full && full.length > 0) return full; const named = entry.displayName?.trim(); return named && named.length > 0 ? named : entry.username; } diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 019e80da0..0ce051611 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -109,6 +109,10 @@ export async function resolveAccountAccess(auth: { id: true, username: true, displayName: true, + // v1.37.2 — shown in front of a delegate on the switcher + banner, + // ahead of the greeting name. A deliberate disclosure of the owner's + // full name to everyone they share with; see `accountLabel`. + fullName: true, managedProfileAt: true, }, }, @@ -126,6 +130,7 @@ export async function resolveAccountAccess(auth: { accountId: grant.grantor.id, username: grant.grantor.username, displayName: grant.grantor.displayName, + fullName: grant.grantor.fullName, access: level === "read" ? "read" : "write", level, recordKind: