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

+29
View File
@@ -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.
+8
View File
@@ -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?",
+8
View File
@@ -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?",
+8
View File
@@ -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}?",
+8
View File
@@ -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} ?",
+8
View File
@@ -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}?",
+8
View File
@@ -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}?",
@@ -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() {