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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 10:12:22 +02:00
1 parent 533400ce60
commit d82e29737b
13 files changed
+451 -7

No files matched your search

@@ -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<typeof import("@/lib/queries/use-account-grants")>();
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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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(<GrantInviteCard />);
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(<GrantInviteCard />);
// 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(<GrantsReceivedCard />);
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(<GrantsReceivedCard />);
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(<GrantsReceivedCard />);
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, string | number>): 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");
});
});
@@ -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<GrantAccessLevel>("READ");
const [error, setError] = useState<string | null>(null);
const [invited, setInvited] = useState<string | null>(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")}
</p>
</div>
<fieldset className="space-y-1.5" data-slot="grant-invite-access">
{/* The `Label` primitive is a `<label>` and cannot name a group, so
the legend mirrors its type and its trailing colon by hand rather
than mislabelling one radio as the heading of both. */}
<legend className="pl-1 text-sm leading-none font-medium after:content-[':']">
{t("recordSharing.invite.accessLegend")}
</legend>
<div className="space-y-2">
<AccessOption
level="READ"
selected={access === "READ"}
onSelect={() => setAccess("READ")}
label={t("recordSharing.invite.accessReadLabel")}
capability={t("recordSharing.invite.accessReadCapability")}
/>
<AccessOption
level="WRITE"
selected={access === "WRITE"}
onSelect={() => setAccess("WRITE")}
label={t("recordSharing.invite.accessWriteLabel")}
capability={t("recordSharing.invite.accessWriteCapability")}
/>
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="grant-invite-expires">
{t("recordSharing.invite.expiresLabel")}
@@ -122,6 +159,63 @@ export function GrantInviteCard() {
);
}
/** The two levels an invitation can carry, as the client names them. */
export type GrantAccessLevel = "READ" | "WRITE";
/**
* One level, with the sentence that says what it actually does.
*
* The capability line is not decoration and it is not a tooltip: it is the
* part the owner is consenting to, so it sits under the label where it has to
* be read past rather than behind an info icon. Muted because it describes the
* option rather than being the option (UI-STANDARDS §3), `text-xs` because
* that is the meta floor.
*/
function AccessOption({
level,
selected,
onSelect,
label,
capability,
}: {
level: GrantAccessLevel;
selected: boolean;
onSelect: () => void;
label: string;
capability: string;
}) {
return (
<label
data-slot="grant-invite-access-option"
data-access={level}
data-selected={selected ? "true" : "false"}
className={[
"flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
>
<input
type="radio"
name="grant-invite-access"
value={level}
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span className="space-y-1">
<span className="text-foreground block text-sm font-medium">
{label}
</span>
<span className="text-muted-foreground block text-xs">
{capability}
</span>
</span>
</label>
);
}
/**
* The chosen day, as the instant access stops.
*
@@ -46,10 +46,18 @@ export function GrantRowItem({
/** What ended it reads differently depending on who is looking. */
side,
actions,
notice,
}: {
grant: GrantRow;
side: "given" | "received";
actions?: ReactNode;
/**
* Something the person has to read before they act on this row, rendered in
* the content column rather than beside the button: consent copy is content
* (UI-STANDARDS §3), and a sentence squeezed into the action column would
* wrap into a stripe nobody reads.
*/
notice?: ReactNode;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -61,6 +69,11 @@ export function GrantRowItem({
data-slot="grant-row"
data-grant-id={grant.id}
data-grant-state={grant.state}
// The level, as an attribute rather than only as a sentence: the browser
// journey has to be able to prove that the level a person picked on the
// invitation form is the level the server stored, and asserting on
// viewport text would pin the copy instead of the value.
data-grant-access={grant.access}
className="flex flex-wrap items-start justify-between gap-3 py-3"
>
<div className="min-w-0 flex-1 space-y-1">
@@ -113,6 +126,7 @@ export function GrantRowItem({
</div>
)}
</dl>
{notice}
</div>
{actions && <div className="flex shrink-0 items-center">{actions}</div>}
</li>
@@ -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, string | number>) => 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(" ");
}
@@ -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() {
</SettingsCard>
);
/**
* 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 (
<p data-slot="grant-write-consent" className="text-foreground text-sm">
{t("recordSharing.received.writeConsent", {
name: accountLabel(grant.account),
})}
</p>
);
}
function renderActions(grant: GrantRow) {
if (grant.state === "PENDING") {
return (
+7
View File
@@ -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() {