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] 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() {