diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 01b8633c3..896cdae75 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12508,11 +12508,15 @@ paths: tags: - Account sharing summary: Invite an account to this record - description: Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing - until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an - authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a - silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can - neither invite nor re-delegate. + description: "Offers access to somebody already registered on this instance, at READ, WRITE or MANAGE and over the whole + record or named sections; the grant confers nothing until they accept it. An identifier that names no account + answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 + invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while + acting on another account, so a delegate can neither invite nor re-delegate. Offering MANAGE is step-up gated + (`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401 + `auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else + happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not + authenticated' to a caller it had just authenticated." requestBody: required: true content: @@ -12534,13 +12538,38 @@ paths: default: READ description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. - Omitted means READ, so a client that predates the field keeps working. The level is fixed when the - invitation is written: no endpoint raises a live grant, because that would widen what the delegate - accepted without asking them again. The way up is a new invitation the delegate accepts." + MANAGE can additionally change and remove entries, including ones the owner wrote, and read the + generated insights; it never reaches the identity surfaces (login, second factor, connections, + tokens, settings, or who else has access). Omitted means READ, so a client that predates the field + keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, + because that would widen what the delegate accepted without asking them again. The way up is a new + invitation the delegate accepts." type: string enum: - READ - WRITE + - MANAGE + scope: + description: "The sections of the record this invitation opens. Omitted or null means the entire record, which is what + every grant written before v1.37.0 means and what an owner who does not narrow still gets. A + non-null value is a non-empty array of section keys from the closed vocabulary; an empty array, an + unknown key and the whole-record sentinel `record` are each refused. A stored scope never grows: a + section added by a later release is not in a set an owner ticked before it existed." + anyOf: + - minItems: 1 + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + - type: "null" required: - identifier responses: @@ -12550,8 +12579,22 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountGrantEnvelope" - "401": *a1 - "403": *a20 + "401": + description: "Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode: + auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": + description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another + account (grant management is never delegable), or `sharing.invite.manage_browser_only` when a Bearer caller + tried to offer MANAGE — the step-up that gates it is cookie-only by construction, so the native client + cannot mint it and should route the person to a browser rather than surface an error. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "404": description: No account on this instance carries that identifier. content: @@ -12566,8 +12609,10 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": - description: "Validation failed, or the invitation named the caller's own account (`meta.errorCode: - sharing.invite.self`)." + description: "Validation failed, the invitation named the caller's own account (`meta.errorCode: sharing.invite.self`), + or the scope cannot mean anything (`meta.errorCode: sharing.invite.invalid_scope` from the domain module; + the request-shape refusal for an empty array, an unknown section or a scope beside MANAGE arrives as the + ordinary multi-issue envelope naming the `scope` field)." content: application/json: schema: @@ -36698,20 +36743,61 @@ components: enum: - read - write + - manage + description: The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one + server-side expression — and kept because shipped clients read it. New code reads `level`. + level: + type: string + enum: + - read + - write + - manage + description: The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable + surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management), + which stay refused on an invited record at every level. Render it; never derive it from `canWrite`. + sections: + anyOf: + - type: array + items: + $ref: "#/components/schemas/ShareSection" + - type: "null" + description: The sections of that record this caller may open, or null for the entire record. Null is first-class and is + what every pre-v1.37.0 grant carries; a MANAGE grant always carries it too. A non-null array names the open + sections and NOTHING else answers — a client that switches into a scoped record without honouring this gets + the standard 403 on every out-of-scope route, fail-closed and byte-identical to every other sharing refusal, + which is safe but reads as a wall of errors. An empty array means the grant opens nothing. Ordered by the + consent screen's reading order. canWrite: type: boolean - description: "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It - never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never - derive it." + description: "Whether this caller may ADD to that record: true for an accepted WRITE or MANAGE grant, false for a READ + one. On its own it never means edit or delete — a WRITE grant adds and does nothing more. What MANAGE + additionally admits is said by `level`. Resolved server-side. Render it; never derive it." required: - accountId - username - displayName - access + - level + - sections - canWrite additionalProperties: false description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL here would resolve to a refusal. Clients paint their initials fallback." + ShareSection: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by + clustering the delegable read surfaces rather than from the module list — the two answer different questions and + never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this + enum: a scope that named the whole record would be a narrowing that means its own absence." RecordActivityEnvelope: type: object properties: @@ -36843,6 +36929,18 @@ components: enum: - READ - WRITE + - MANAGE + scope: + anyOf: + - type: array + items: + $ref: "#/components/schemas/ShareSection" + - type: "null" + description: The sections this grant opens, or null for the entire record. Null is a first-class value and the one every + grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A + MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of + a stored scope the server cannot parse — and the server will refuse every section for it, so render it as + nothing rather than as everything. Ordered by the consent screen's own reading order. state: $ref: "#/components/schemas/AccountGrantState" invitedAt: @@ -36874,6 +36972,7 @@ components: - id - account - access + - scope - state - invitedAt - acceptedAt @@ -36960,6 +37059,18 @@ components: enum: - READ - WRITE + - MANAGE + scope: + anyOf: + - type: array + items: + $ref: "#/components/schemas/ShareSection" + - type: "null" + description: The sections this grant opens, or null for the entire record. Null is a first-class value and the one every + grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A + MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of + a stored scope the server cannot parse — and the server will refuse every section for it, so render it as + nothing rather than as everything. Ordered by the consent screen's own reading order. state: $ref: "#/components/schemas/AccountGrantState" invitedAt: @@ -36997,6 +37108,7 @@ components: - id - account - access + - scope - state - invitedAt - acceptedAt @@ -37062,6 +37174,7 @@ components: enum: - READ - WRITE + - MANAGE required: - accountId - username diff --git a/messages/de.json b/messages/de.json index c75f67bee..9a2f19e82 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9418,6 +9418,10 @@ "row": { "readOnly": "Darf die Akte lesen", "canWrite": "Darf lesen und Einträge anlegen", + "canManage": "Darf lesen, Einträge anlegen sowie Einträge ändern und löschen", + "entireRecord": "Umfasst die gesamte Akte", + "sections": "Umfasst: {sections}", + "noSections": "Umfasst nichts: dieser Zugriff öffnet keinen Teil der Akte", "invited": "Eingeladen", "accepted": "Angenommen", "expires": "Läuft ab", @@ -9439,6 +9443,19 @@ "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, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.", + "accessManageLabel": "Darf verwalten", + "accessManageCapability": "Sie kann Einträge in deiner Akte anlegen, ändern und löschen, auch solche, die du selbst erfasst hast, und sie kann die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.", + "manageStepUpNote": "Dafür wird dein zweiter Faktor abgefragt, deshalb geht das nur im Browser.", + "scopeLegend": "Was die Person öffnen darf", + "scopeAllLabel": "Gesamte Akte", + "scopeAllCapability": "Jeder Teil davon, so wie das Teilen bisher funktioniert hat.", + "scopeSomeLabel": "Nur die Bereiche, die du auswählst", + "scopeSomeCapability": "Alles, was du nicht auswählst, bleibt für sie verschlossen.", + "scopeManageNote": "Verwaltender Zugriff umfasst immer die gesamte Akte.", + "scopeLeakNote": "Ein Bereich kann in seinen Notizen und Bezeichnungen auch den Rest deiner Gesundheit erwähnen. Wer einen Bereich bekommt, bekommt alles, was darin steht.", + "scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.", + "scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.", + "scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.", "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", @@ -9449,7 +9466,10 @@ "errorSelf": "Das ist dein eigenes Konto.", "errorInvalid": "Bitte einen Benutzernamen oder eine E-Mail-Adresse eingeben.", "errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.", - "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen." + "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.", + "errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.", + "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben, weil dabei dein zweiter Faktor abgefragt wird.", + "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut." }, "given": { "title": "Wer meine Akte öffnen kann", @@ -9474,6 +9494,7 @@ "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}.", + "manageConsent": "Wenn du annimmst, kannst du die Akte von {name} ergänzen und außerdem ändern oder löschen, was schon darin steht, auch Einträge von {name} selbst. Alles, was du tust, steht mit deinem Namen in der Aktivitätsübersicht, und {name} kann deinen Zugriff jederzeit beenden.", "open": "Öffnen", "renounce": "Zurückgeben", "renounceTitle": "Akte von {name} zurückgeben?", @@ -9516,6 +9537,40 @@ "medicationCreate": "{name} hat ein Medikament angelegt", "medicationIntake": "{name} hat eine Dosis eingetragen", "medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen" + }, + "section": { + "measurements": { + "label": "Messwerte", + "detail": "Gewicht, Blutdruck, Puls, Glukose, Schlaf, Training und die Werte, die du selbst verfolgst." + }, + "medications": { + "label": "Medikamente", + "detail": "Die Medikamentenliste, ihre Zeitpläne, eingenommene Dosen, Nebenwirkungen und der Bestand." + }, + "labs": { + "label": "Laborwerte", + "detail": "Laborergebnisse und die Liste der Kennwerte dahinter." + }, + "profile": { + "label": "Gesundheitliche Vorgeschichte", + "detail": "Allergien, Familienanamnese und die Angaben in deinem Gesundheitsprofil." + }, + "illness": { + "label": "Krankheiten", + "detail": "Krankheitsverläufe und die Tageseinträge darunter." + }, + "mind": { + "label": "Stimmung und Psyche", + "detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen." + }, + "cycle": { + "label": "Zyklus", + "detail": "Die Zyklusaufzeichnung." + }, + "documents": { + "label": "Dokumente", + "detail": "Alles, was im Dokumententresor liegt." + } } } } diff --git a/messages/en.json b/messages/en.json index 6b46a70ef..e30323d2d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9418,6 +9418,10 @@ "row": { "readOnly": "Can read the record", "canWrite": "Can read and add entries", + "canManage": "Can read the record, add to it, and change or remove what is in it", + "entireRecord": "Covers the entire record", + "sections": "Covers: {sections}", + "noSections": "Covers nothing — this access opens no part of the record", "invited": "Invited", "accepted": "Accepted", "expires": "Lapses", @@ -9439,6 +9443,19 @@ "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, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.", + "accessManageLabel": "Can manage", + "accessManageCapability": "They can add, change and remove entries in your record, including ones you entered yourself, and they can read the insights generated from it. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.", + "manageStepUpNote": "Offering this asks for your second factor, so it can only be done in a browser.", + "scopeLegend": "What they can open", + "scopeAllLabel": "Entire record", + "scopeAllCapability": "Every part of it, the way sharing has always worked.", + "scopeSomeLabel": "Only the sections you pick", + "scopeSomeCapability": "Everything you do not pick stays closed to them.", + "scopeManageNote": "Manage access always covers the entire record.", + "scopeLeakNote": "A section can mention the rest of your health in its notes and names. Sharing a section shares everything written in it.", + "scopeAggregateNote": "The dashboard overview, health score and daily digest only appear when the entire record is shared.", + "scopeDocumentsNote": "A document can contain anything. Share this only if they may read all of it.", + "scopeEmptyHint": "Pick at least one section, or go back to sharing the entire record.", "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", @@ -9449,7 +9466,10 @@ "errorSelf": "That is your own account.", "errorInvalid": "Please enter a username or e-mail address.", "errorRateLimited": "Too many invitations. Please try again later.", - "errorFailed": "The invitation could not be sent. Please try again." + "errorFailed": "The invitation could not be sent. Please try again.", + "errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.", + "errorManageBrowserOnly": "Manage access can only be offered in a browser, because it asks for your second factor.", + "errorStepUp": "Confirm your second factor, then try again." }, "given": { "title": "Who can open my record", @@ -9474,6 +9494,7 @@ "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.", + "manageConsent": "If you accept, you can add to {name}'s record and also change or remove what is already in it, including entries {name} made themselves. Everything you do is recorded under your name in their activity view, and {name} can end your access at any time.", "open": "Open", "renounce": "Hand back", "renounceTitle": "Hand {name}'s record back?", @@ -9516,6 +9537,40 @@ "medicationCreate": "{name} added a medication", "medicationIntake": "{name} marked a dose", "medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused" + }, + "section": { + "measurements": { + "label": "Readings", + "detail": "Weight, blood pressure, pulse, glucose, sleep, workouts and the values you track." + }, + "medications": { + "label": "Medications", + "detail": "The medication list, its schedules, the doses taken, side effects and stock." + }, + "labs": { + "label": "Lab results", + "detail": "Lab results and the list of analytes behind them." + }, + "profile": { + "label": "Health background", + "detail": "Allergies, family history and the facts in your health profile." + }, + "illness": { + "label": "Illness", + "detail": "Illness episodes and the day logs kept under them." + }, + "mind": { + "label": "Mood and mind", + "detail": "Mood entries and the history of the mental-health questionnaires." + }, + "cycle": { + "label": "Cycle", + "detail": "Cycle tracking." + }, + "documents": { + "label": "Documents", + "detail": "Everything stored in the document vault." + } } } } diff --git a/messages/es.json b/messages/es.json index 9160aa09f..eb7ba6b3f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9418,6 +9418,10 @@ "row": { "readOnly": "Puede leer el historial", "canWrite": "Puede leer y añadir entradas", + "canManage": "Puede leer el historial, añadir entradas y cambiar o eliminar lo que hay en él", + "entireRecord": "Cubre todo el historial", + "sections": "Cubre: {sections}", + "noSections": "No cubre nada: este acceso no abre ninguna parte del historial", "invited": "Invitado", "accepted": "Aceptado", "expires": "Caduca", @@ -9439,6 +9443,19 @@ "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 a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.", + "accessManageLabel": "Puede gestionar", + "accessManageCapability": "Puede añadir, cambiar y eliminar entradas de tu ficha, incluidas las que has escrito tú, y puede leer los análisis generados a partir de ella. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.", + "manageStepUpNote": "Ofrecerlo pide tu segundo factor, así que solo se puede hacer en un navegador.", + "scopeLegend": "Qué puede abrir", + "scopeAllLabel": "Todo el historial", + "scopeAllCapability": "Todas sus partes, tal y como ha funcionado siempre el uso compartido.", + "scopeSomeLabel": "Solo las secciones que elijas", + "scopeSomeCapability": "Todo lo que no elijas le queda cerrado.", + "scopeManageNote": "El acceso de gestión siempre cubre todo el historial.", + "scopeLeakNote": "Una sección puede mencionar el resto de tu salud en sus notas y en sus nombres. Compartir una sección comparte todo lo que hay escrito en ella.", + "scopeAggregateNote": "El resumen del panel, la puntuación de salud y el resumen diario solo aparecen cuando se comparte todo el historial.", + "scopeDocumentsNote": "Un documento puede contener cualquier cosa. Comparte esta sección solo si esa persona puede leerlo todo.", + "scopeEmptyHint": "Elige al menos una sección o vuelve a compartir todo el historial.", "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", @@ -9449,7 +9466,10 @@ "errorSelf": "Esa es tu propia cuenta.", "errorInvalid": "Introduce un usuario o una dirección de correo.", "errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.", - "errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo." + "errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.", + "errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.", + "errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador, porque pide tu segundo factor.", + "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo." }, "given": { "title": "Quién puede abrir mi historial", @@ -9474,6 +9494,7 @@ "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.", + "manageConsent": "Si aceptas, podrás añadir cosas al historial de {name} y también cambiar o eliminar lo que ya hay, incluidas las entradas del propio {name}. Todo lo que hagas queda registrado con tu nombre en su vista de actividad, y {name} puede terminar tu acceso cuando quiera.", "open": "Abrir", "renounce": "Devolver", "renounceTitle": "¿Devolver el historial de {name}?", @@ -9516,6 +9537,40 @@ "medicationCreate": "{name} añadió un medicamento", "medicationIntake": "{name} registró una dosis", "medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado" + }, + "section": { + "measurements": { + "label": "Mediciones", + "detail": "Peso, tensión arterial, pulso, glucosa, sueño, entrenamientos y los valores que sigues." + }, + "medications": { + "label": "Medicamentos", + "detail": "La lista de medicamentos, sus pautas, las dosis tomadas, los efectos secundarios y las existencias." + }, + "labs": { + "label": "Resultados de laboratorio", + "detail": "Los resultados de laboratorio y la lista de analitos que hay detrás." + }, + "profile": { + "label": "Antecedentes de salud", + "detail": "Alergias, antecedentes familiares y los datos de tu perfil de salud." + }, + "illness": { + "label": "Enfermedades", + "detail": "Los episodios de enfermedad y los registros diarios asociados." + }, + "mind": { + "label": "Ánimo y bienestar mental", + "detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental." + }, + "cycle": { + "label": "Ciclo", + "detail": "El seguimiento del ciclo." + }, + "documents": { + "label": "Documentos", + "detail": "Todo lo que está guardado en el archivo de documentos." + } } } } diff --git a/messages/fr.json b/messages/fr.json index 6f7c15361..13e690d84 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9418,6 +9418,10 @@ "row": { "readOnly": "Peut lire le dossier", "canWrite": "Peut lire et ajouter des entrées", + "canManage": "Peut lire le dossier, y ajouter des entrées et modifier ou supprimer ce qui s'y trouve", + "entireRecord": "Couvre l'intégralité du dossier", + "sections": "Couvre : {sections}", + "noSections": "Ne couvre rien : cet accès n'ouvre aucune partie du dossier", "invited": "Invité le", "accepted": "Accepté le", "expires": "Expire le", @@ -9439,6 +9443,19 @@ "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 au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.", + "accessManageLabel": "Peut gérer", + "accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées de votre dossier, y compris celles que vous avez saisies vous-même, et lire les analyses qui en sont tirées. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.", + "manageStepUpNote": "Cette offre demande votre deuxième facteur, elle n'est donc possible que dans un navigateur.", + "scopeLegend": "Ce que cette personne peut ouvrir", + "scopeAllLabel": "Dossier complet", + "scopeAllCapability": "Toutes ses parties, comme le partage a toujours fonctionné.", + "scopeSomeLabel": "Seulement les rubriques que vous choisissez", + "scopeSomeCapability": "Tout ce que vous ne choisissez pas lui reste fermé.", + "scopeManageNote": "Un accès de gestion couvre toujours l'intégralité du dossier.", + "scopeLeakNote": "Une rubrique peut évoquer le reste de votre santé dans ses notes et ses intitulés. Partager une rubrique, c'est partager tout ce qui y est écrit.", + "scopeAggregateNote": "La vue d'ensemble du tableau de bord, le score de santé et le résumé quotidien n'apparaissent que si le dossier entier est partagé.", + "scopeDocumentsNote": "Un document peut contenir n'importe quoi. Ne partagez cette rubrique que si la personne peut tout lire.", + "scopeEmptyHint": "Choisissez au moins une rubrique, ou revenez au partage du dossier complet.", "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", @@ -9449,7 +9466,10 @@ "errorSelf": "C'est votre propre compte.", "errorInvalid": "Saisissez un nom d'utilisateur ou une adresse e-mail.", "errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.", - "errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer." + "errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.", + "errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.", + "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur, car il demande votre deuxième facteur.", + "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez." }, "given": { "title": "Qui peut ouvrir mon dossier", @@ -9474,6 +9494,7 @@ "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.", + "manageConsent": "Si vous acceptez, vous pourrez compléter le dossier de {name} mais aussi modifier ou supprimer ce qui s'y trouve déjà, y compris les entrées de {name}. Tout ce que vous faites apparaît à votre nom dans son journal d'activité, et {name} peut mettre fin à votre accès à tout moment.", "open": "Ouvrir", "renounce": "Rendre", "renounceTitle": "Rendre le dossier de {name} ?", @@ -9516,6 +9537,40 @@ "medicationCreate": "{name} a ajouté un médicament", "medicationIntake": "{name} a enregistré une prise", "medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé" + }, + "section": { + "measurements": { + "label": "Mesures", + "detail": "Poids, tension artérielle, pouls, glycémie, sommeil, séances et les valeurs que vous suivez." + }, + "medications": { + "label": "Médicaments", + "detail": "La liste des médicaments, leurs horaires, les prises, les effets indésirables et les stocks." + }, + "labs": { + "label": "Résultats de laboratoire", + "detail": "Les résultats de laboratoire et la liste des analytes correspondants." + }, + "profile": { + "label": "Antécédents de santé", + "detail": "Allergies, antécédents familiaux et les informations de votre profil de santé." + }, + "illness": { + "label": "Maladies", + "detail": "Les épisodes de maladie et les journaux quotidiens qui s'y rattachent." + }, + "mind": { + "label": "Humeur et santé mentale", + "detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale." + }, + "cycle": { + "label": "Cycle", + "detail": "Le suivi du cycle." + }, + "documents": { + "label": "Documents", + "detail": "Tout ce qui est conservé dans le coffre à documents." + } } } } diff --git a/messages/it.json b/messages/it.json index 1a11a9ab5..aec50ff84 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9418,6 +9418,10 @@ "row": { "readOnly": "Può leggere la cartella", "canWrite": "Può leggere e aggiungere voci", + "canManage": "Può leggere la cartella, aggiungere voci e modificare o eliminare ciò che contiene", + "entireRecord": "Copre l'intera cartella", + "sections": "Copre: {sections}", + "noSections": "Non copre nulla: questo accesso non apre alcuna parte della cartella", "invited": "Invitato il", "accepted": "Accettato il", "expires": "Scade il", @@ -9439,6 +9443,19 @@ "accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.", "accessWriteLabel": "Può vedere e aggiungere", "accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.", + "accessManageLabel": "Può gestire", + "accessManageCapability": "Può aggiungere, modificare ed eliminare voci della tua cartella, comprese quelle inserite da te, e può leggere le analisi generate a partire da essa. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.", + "manageStepUpNote": "Per offrirlo viene richiesto il tuo secondo fattore, quindi si può fare solo da browser.", + "scopeLegend": "Che cosa può aprire", + "scopeAllLabel": "Intera cartella", + "scopeAllCapability": "Ogni sua parte, come la condivisione ha sempre funzionato.", + "scopeSomeLabel": "Solo le sezioni che scegli", + "scopeSomeCapability": "Tutto ciò che non scegli resta chiuso per lei.", + "scopeManageNote": "L'accesso di gestione copre sempre l'intera cartella.", + "scopeLeakNote": "Una sezione può menzionare il resto della tua salute nelle note e nei nomi. Condividere una sezione significa condividere tutto ciò che vi è scritto.", + "scopeAggregateNote": "La panoramica della dashboard, il punteggio di salute e il riepilogo giornaliero compaiono solo se viene condivisa l'intera cartella.", + "scopeDocumentsNote": "Un documento può contenere qualsiasi cosa. Condividi questa sezione solo se quella persona può leggere tutto.", + "scopeEmptyHint": "Scegli almeno una sezione oppure torna a condividere l'intera cartella.", "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", @@ -9449,7 +9466,10 @@ "errorSelf": "Questo è il tuo account.", "errorInvalid": "Inserisci un nome utente o un indirizzo e-mail.", "errorRateLimited": "Troppi inviti. Riprova più tardi.", - "errorFailed": "Non è stato possibile inviare l'invito. Riprova." + "errorFailed": "Non è stato possibile inviare l'invito. Riprova.", + "errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.", + "errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser, perché richiede il tuo secondo fattore.", + "errorStepUp": "Conferma il tuo secondo fattore e riprova." }, "given": { "title": "Chi può aprire la mia cartella", @@ -9474,6 +9494,7 @@ "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}.", + "manageConsent": "Se accetti, potrai aggiungere voci alla cartella di {name} e anche modificare o eliminare quelle già presenti, comprese quelle inserite da {name}. Tutto ciò che fai viene registrato con il tuo nome nella sua vista attività, e {name} può terminare il tuo accesso in qualsiasi momento.", "open": "Apri", "renounce": "Restituisci", "renounceTitle": "Restituire la cartella di {name}?", @@ -9516,6 +9537,40 @@ "medicationCreate": "{name} ha aggiunto un farmaco", "medicationIntake": "{name} ha registrato una dose", "medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato" + }, + "section": { + "measurements": { + "label": "Misurazioni", + "detail": "Peso, pressione, polso, glicemia, sonno, allenamenti e i valori che monitori." + }, + "medications": { + "label": "Farmaci", + "detail": "L'elenco dei farmaci, gli orari, le dosi assunte, gli effetti collaterali e le scorte." + }, + "labs": { + "label": "Esami di laboratorio", + "detail": "I risultati di laboratorio e l'elenco degli analiti che li compongono." + }, + "profile": { + "label": "Anamnesi", + "detail": "Allergie, storia familiare e i dati del tuo profilo di salute." + }, + "illness": { + "label": "Malattie", + "detail": "Gli episodi di malattia e i diari giornalieri collegati." + }, + "mind": { + "label": "Umore e benessere mentale", + "detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale." + }, + "cycle": { + "label": "Ciclo", + "detail": "Il monitoraggio del ciclo." + }, + "documents": { + "label": "Documenti", + "detail": "Tutto ciò che è conservato nell'archivio documenti." + } } } } diff --git a/messages/pl.json b/messages/pl.json index 5f5bc482c..dba4b4487 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9418,6 +9418,10 @@ "row": { "readOnly": "Może czytać kartę", "canWrite": "Może czytać i dodawać wpisy", + "canManage": "Może czytać kartotekę, dodawać wpisy oraz zmieniać i usuwać to, co w niej jest", + "entireRecord": "Obejmuje całą kartotekę", + "sections": "Obejmuje: {sections}", + "noSections": "Nie obejmuje niczego: ten dostęp nie otwiera żadnej części kartoteki", "invited": "Zaproszono", "accepted": "Zaakceptowano", "expires": "Wygasa", @@ -9439,6 +9443,19 @@ "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 kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.", + "accessManageLabel": "Może zarządzać", + "accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy w Twojej kartotece, także te, które wprowadziłeś samodzielnie, oraz czytać wygenerowane na jej podstawie analizy. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.", + "manageStepUpNote": "Przy takim zaproszeniu prosimy o drugi składnik logowania, dlatego można to zrobić tylko w przeglądarce.", + "scopeLegend": "Co ta osoba może otworzyć", + "scopeAllLabel": "Cała kartoteka", + "scopeAllCapability": "Każda jej część, tak jak udostępnianie działało do tej pory.", + "scopeSomeLabel": "Tylko wybrane przez Ciebie sekcje", + "scopeSomeCapability": "Wszystko, czego nie wybierzesz, pozostaje dla niej zamknięte.", + "scopeManageNote": "Dostęp zarządzający zawsze obejmuje całą kartotekę.", + "scopeLeakNote": "Sekcja może wspominać o reszcie Twojego zdrowia w notatkach i nazwach. Udostępnienie sekcji oznacza udostępnienie wszystkiego, co w niej zapisano.", + "scopeAggregateNote": "Przegląd na pulpicie, wynik zdrowia i dzienne podsumowanie pojawiają się tylko wtedy, gdy udostępniona jest cała kartoteka.", + "scopeDocumentsNote": "Dokument może zawierać wszystko. Udostępnij tę sekcję tylko wtedy, gdy ta osoba może przeczytać całość.", + "scopeEmptyHint": "Wybierz co najmniej jedną sekcję albo wróć do udostępniania całej kartoteki.", "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", @@ -9449,7 +9466,10 @@ "errorSelf": "To twoje własne konto.", "errorInvalid": "Podaj nazwę użytkownika lub adres e-mail.", "errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.", - "errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie." + "errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.", + "errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.", + "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce, ponieważ wymaga drugiego składnika logowania.", + "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie." }, "given": { "title": "Kto może otworzyć moją kartę", @@ -9474,6 +9494,7 @@ "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}.", + "manageConsent": "Jeśli zaakceptujesz, będziesz mieć możliwość uzupełniania kartoteki użytkownika {name}, a także zmieniania i usuwania tego, co już w niej jest, łącznie z wpisami samego {name}. Wszystko, co zrobisz, zostanie zapisane pod Twoim imieniem w widoku aktywności, a {name} może zakończyć Twój dostęp w dowolnej chwili.", "open": "Otwórz", "renounce": "Zwróć", "renounceTitle": "Zwrócić kartę użytkownika {name}?", @@ -9516,6 +9537,40 @@ "medicationCreate": "{name} dodał lek", "medicationIntake": "{name} zapisał przyjęcie dawki", "medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)" + }, + "section": { + "measurements": { + "label": "Pomiary", + "detail": "Waga, ciśnienie, tętno, glukoza, sen, treningi i śledzone przez Ciebie wartości." + }, + "medications": { + "label": "Leki", + "detail": "Lista leków, harmonogramy, przyjęte dawki, działania niepożądane i stan zapasów." + }, + "labs": { + "label": "Wyniki badań", + "detail": "Wyniki laboratoryjne i lista analitów, które za nimi stoją." + }, + "profile": { + "label": "Historia zdrowia", + "detail": "Alergie, wywiad rodzinny i dane z Twojego profilu zdrowia." + }, + "illness": { + "label": "Choroby", + "detail": "Epizody chorobowe i prowadzone przy nich wpisy dzienne." + }, + "mind": { + "label": "Nastrój i zdrowie psychiczne", + "detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego." + }, + "cycle": { + "label": "Cykl", + "detail": "Śledzenie cyklu." + }, + "documents": { + "label": "Dokumenty", + "detail": "Wszystko, co jest przechowywane w sejfie dokumentów." + } } } } diff --git a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts new file mode 100644 index 000000000..1c290d0a5 --- /dev/null +++ b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts @@ -0,0 +1,290 @@ +/** + * `POST /api/account/grants` — the two things v1.37.0 adds to an invitation. + * + * The sections it opens, and the step-up that gates the level which can delete + * things. Both are asserted on what the route DID rather than on what it + * answered with: the scope on the `data` object handed to `accountGrant.create` + * (a response echoing the request would pass either way, and the failure this + * pins is the one where a field is validated, annotated on, and dropped on the + * way to the database), and the step-up on the gate having been CALLED, plus + * the absence of a row when it refuses. + * + * What lives where, asserted rather than assumed: an absent `scope` becomes + * NULL in the schema and nowhere else, and `inviteGrant` writes `Prisma.DbNull` + * for it — a bare `null` on a nullable Json column stores the JSON value + * `null`, which the fail-closed normaliser reads as the empty set. That is a + * grant opening NOTHING written by the path meant to open everything, one + * keystroke away, so it is pinned. + * + * Mutation checks, run and recorded in the stream's report: + * - drop the enum from the `scope` schema (`z.array(z.string())`) → the + * unknown-section case stops being refused and goes red; + * - make the step-up branch unconditional (`if (true)`) → "a read invitation + * is not gated" goes red; remove the branch entirely → the MANAGE legs go + * red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>(h: T): T => + h, + requireAuth: vi.fn(), + requireFreshMfaIfEnrolled: vi.fn(), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + accountGrant: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { POST } from "../route"; +import { + requireAuth, + requireFreshMfaIfEnrolled, + StepUpRequiredError, +} from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; + +const OWNER = { id: "owner-1" }; +const INVITEE = { id: "invitee-1", username: "housemate", displayName: null }; + +function request(body: Record): NextRequest { + return new NextRequest("http://localhost/api/account/grants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** The `data` object the route's write reached the client with. */ +function writtenRow(): Record { + const calls = vi.mocked(prisma.accountGrant.create).mock.calls; + expect(calls).toHaveLength(1); + return (calls[0][0] as { data: Record }).data; +} + +/** Resolve the caller on the transport the argument names. */ +function callerOn(transport: "cookie" | "bearer") { + vi.mocked(requireAuth).mockResolvedValue({ + user: OWNER, + authMethod: transport, + } as unknown as Awaited>); +} + +beforeEach(() => { + vi.clearAllMocks(); + callerOn("cookie"); + vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue({ + user: OWNER, + } as unknown as Awaited>); + vi.mocked(prisma.user.findFirst).mockResolvedValue( + INVITEE as never as Awaited>, + ); + vi.mocked(prisma.accountGrant.create).mockImplementation((async (args: { + data: Record; + }) => ({ + id: "grant-1", + grantorId: OWNER.id, + granteeId: INVITEE.id, + acceptedAt: null, + revokedAt: null, + revokedBy: null, + lastUsedAt: null, + expiresAt: null, + scopeJson: null, + ...args.data, + })) as never); +}); + +describe("the sections an invitation opens", () => { + it("stores the set the owner picked", async () => { + const res = await POST( + request({ identifier: "housemate", scope: ["labs", "medications"] }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().scopeJson).toEqual(["labs", "medications"]); + }); + + it("stores the whole record as DbNull, not as a JSON null", async () => { + // The one-keystroke inversion: a bare `null` on a nullable Json column is + // the stored JSON value `null`, which the fail-closed normaliser resolves + // to the empty set — the entire record turned into nothing at all. + const res = await POST(request({ identifier: "housemate" })); + + expect(res.status).toBe(201); + expect(writtenRow().scopeJson).toBe(Prisma.DbNull); + }); + + it("treats an explicit null the same as an absent field", async () => { + const res = await POST(request({ identifier: "housemate", scope: null })); + + expect(res.status).toBe(201); + expect(writtenRow().scopeJson).toBe(Prisma.DbNull); + }); + + it("refuses a section outside the vocabulary, without writing a row", async () => { + const res = await POST( + request({ identifier: "housemate", scope: ["labs", "bank_details"] }), + ); + + expect(res.status).toBe(422); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("refuses the whole-record sentinel as a section", async () => { + // `record` is deliberately not a member of the vocabulary: a narrowing + // that names the whole record is a narrowing that means its own absence, + // and the resolver would refuse every domain for it. + const res = await POST( + request({ identifier: "housemate", scope: ["record"] }), + ); + + expect(res.status).toBe(422); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("refuses an empty set, without writing a row", async () => { + const res = await POST(request({ identifier: "housemate", scope: [] })); + + expect(res.status).toBe(422); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("refuses a scope beside MANAGE, without writing a row", async () => { + // Management is whole-record by construction. "They can do anything, but + // only to part of you" promises a boundary an edit cannot keep. + const res = await POST( + request({ identifier: "housemate", access: "MANAGE", scope: ["labs"] }), + ); + + expect(res.status).toBe(422); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("records the sections on the audit row", async () => { + // "Who was given what" is the question this row exists to answer, and an + // entry naming the level without the sections answers half of it. + await POST(request({ identifier: "housemate", scope: ["mind"] })); + + expect(auditLog).toHaveBeenCalledWith( + "sharing.grant.invited", + expect.objectContaining({ + userId: OWNER.id, + details: expect.objectContaining({ scope: ["mind"] }), + }), + ); + }); + + it("records the whole record as an answer rather than an omission", async () => { + await POST(request({ identifier: "housemate" })); + + const call = vi.mocked(auditLog).mock.calls[0]; + const details = (call[1] as { details: Record }).details; + expect(details).toHaveProperty("scope", null); + }); +}); + +describe("offering MANAGE", () => { + it("mints the row once the step-up is satisfied", async () => { + const res = await POST( + request({ identifier: "housemate", access: "MANAGE" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("MANAGE"); + expect(writtenRow().scopeJson).toBe(Prisma.DbNull); + expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number)); + }); + + it("refuses without a fresh second factor, and writes nothing", async () => { + // The refusal CODE, not merely the absence of a row: a route that threw + // for any other reason would also leave no row, and this leg is meant to + // prove the GATE ran. + vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue( + new StepUpRequiredError(), + ); + + await expect( + POST(request({ identifier: "housemate", access: "MANAGE" })), + ).rejects.toMatchObject({ errorCode: "auth.stepup.required" }); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("does not gate a read or a write invitation", async () => { + // The friction belongs to the level that can delete things. An owner + // sharing a reading with their partner re-proves nothing. + for (const access of ["READ", "WRITE"] as const) { + vi.clearAllMocks(); + callerOn("cookie"); + const res = await POST(request({ identifier: "housemate", access })); + expect(res.status).toBe(201); + expect(requireFreshMfaIfEnrolled).not.toHaveBeenCalled(); + } + }); + + it("refuses a Bearer caller with a code it can act on", async () => { + // The decided consequence of a cookie-only step-up, said comprehensibly. + // Left to fall through, the gate resolves the session cookie, finds none + // and answers "Not authenticated" to a caller the route had just + // authenticated — an obscure failure on the one transport that cannot + // ever succeed here. + callerOn("bearer"); + + const res = await POST( + request({ identifier: "housemate", access: "MANAGE" }), + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("sharing.invite.manage_browser_only"); + expect(requireFreshMfaIfEnrolled).not.toHaveBeenCalled(); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("refuses the Bearer caller before it discloses whether the account exists", async () => { + // The identifier lookup answers 404 for a name nobody carries, which is a + // deliberate disclosure to an authenticated caller — but there is no + // reason to hand it out on a request that cannot succeed. + callerOn("bearer"); + + await POST(request({ identifier: "housemate", access: "MANAGE" })); + + expect(prisma.user.findFirst).not.toHaveBeenCalled(); + }); + + it("lets a Bearer caller keep minting the two levels it always could", async () => { + callerOn("bearer"); + + const res = await POST( + request({ identifier: "housemate", access: "WRITE" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("WRITE"); + }); +}); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 6fb60e03c..0d2dc86f8 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -7,9 +7,19 @@ * client that has to call twice will eventually render half an answer. * * `POST` offers a grant to somebody already registered on this instance, at - * the level the owner chose. There is no route that raises a live grant: the - * level is settled when the invitation is written and the only way to a wider - * one is a fresh invitation the delegate accepts again. + * the level the owner chose and over the sections they chose. There is no + * route that raises a live grant: both are settled when the invitation is + * written and the only way to a wider one is a fresh invitation the delegate + * accepts again. + * + * v1.37.0 — offering MANAGE is the one act on this surface with friction on + * it. It asks for a fresh second factor when the account has one, which makes + * it cookie-only by construction: the step-up gate resolves through the + * session cookie and a token transport carries nothing that could satisfy it. + * That consequence is deliberate rather than an oversight — the native client + * cannot offer management of a health record, and the refusal below says so in + * a code rather than leaving a token caller with an authentication error on a + * request that authenticated fine. * * **Neither is delegable, and that is structural.** Both resolve through bare * `requireAuth()`, which refuses outright while the caller is acting on @@ -31,7 +41,12 @@ */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireAuth, + requireFreshMfaIfEnrolled, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -91,7 +106,8 @@ export const GET = apiHandler(async () => { }); export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const auth = await requireAuth(); + const user = auth.user; const rl = await checkRateLimit( `sharing:invite:${user.id}`, @@ -114,7 +130,38 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { identifier, expiresAt, access } = parsed.data; + const { identifier, expiresAt, access, scope } = parsed.data; + + // ── The step-up, and only on the level that needs one ─────────────────── + // + // MANAGE is the level that can rewrite and delete somebody's health record, + // so offering it is gated on a fresh second factor the way account deletion + // and the encrypted export are. READ and WRITE are untouched: an owner + // sharing a reading with their partner should not have to re-prove anything. + // + // It runs BEFORE the invitee lookup on purpose. That lookup answers 404 for + // an identifier nobody carries, which is a deliberate disclosure to an + // authenticated caller — but there is no reason to hand it out on a request + // that is about to be refused anyway. + // + // The transport is checked first and separately. `requireFreshMfaIfEnrolled` + // escalates to `requireFreshMfa`, which resolves through the session cookie + // and has no Bearer fall-through; a token caller would therefore be told + // "Not authenticated" by a route that had just authenticated them, which is + // the obscure failure this branch exists to prevent. The consequence is + // deliberate and documented (the native client cannot mint MANAGE): it is + // said here in a code a client can branch on, so the app can send the person + // to a browser instead of showing them a login error. + if (access === "MANAGE") { + if (auth.authMethod !== "cookie") { + return apiError( + "Manage access can only be offered from a browser session", + 403, + { errorCode: "sharing.invite.manage_browser_only" }, + ); + } + await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); + } // Either column, case-insensitively — the same lookup the login form does, // so the name somebody signs in with is the name they can be invited by. @@ -142,26 +189,29 @@ export const POST = apiHandler(async (request: NextRequest) => { grantorId: user.id, granteeId: invitee.id, access, - // The entire record, which is what this endpoint has always offered and - // what every stored grant means. Named rather than defaulted, because - // the domain module takes the scope as a required argument for the same - // reason it takes the level as one: a scope nobody chose is not a scope. - // The consent surface that lets an owner narrow it lands beside this - // line, in this release. - scope: null, + // The sections the owner ticked, or null for the entire record. The + // schema is where an absent field becomes null, the same way it is where + // an absent level becomes READ; the domain module takes both as required + // arguments so neither is decided twice. It re-refuses an empty set, an + // unknown key and a scope beside MANAGE underneath this line — the + // schema is the legible refusal, that one is the floor. + scope: scope ?? null, expiresAt: expiresAt ? new Date(expiresAt) : null, }); - // The level is on the audit row, not only in the response: "who was given - // what, and by whom" is the question this row exists to answer, and an - // entry that recorded the invitation without its level would answer half - // of it. + // The level AND the sections are on the audit row, not only in the + // response: "who was given what, and by whom" is the question this row + // exists to answer, and an entry that recorded the invitation without + // either would answer half of it. `null` is written as null rather than + // omitted, because "the entire record" is an answer and a missing key + // would read as a row from before the question was asked. await auditLog("sharing.grant.invited", { userId: user.id, details: { grantId: grant.id, granteeId: invitee.id, access: grant.access, + scope: scope ?? null, }, ipAddress: getClientIp(request), }).catch(() => {}); @@ -171,6 +221,11 @@ export const POST = apiHandler(async (request: NextRequest) => { meta: { grant_id: grant.id, access: grant.access, + // The count rather than the keys: a dashboard wants to know how often + // owners narrow, and which sections one household shares is not a + // thing to spread across the observability pipeline. Zero means the + // entire record. + scope_sections: scope?.length ?? 0, expires: expiresAt !== null && expiresAt !== undefined, }, }); @@ -199,6 +254,16 @@ function grantErrorResponse(err: GrantError): Response { return apiError("That account already has access", 409, { errorCode: "sharing.invite.duplicate", }); + // The request shape refuses the same three cases earlier and more + // legibly (a 422 naming the `scope` field). Reaching this arm means a + // caller got past the schema with a scope the domain module will not + // store — an empty set, a key outside the vocabulary, or any scope beside + // MANAGE. Same status, its own code, so a client can tell "your sections + // are wrong" from "that is your own account". + case "invalid_scope": + return apiError("Those sections cannot be shared", 422, { + errorCode: "sharing.invite.invalid_scope", + }); default: return apiError("Invitation refused", 422, { errorCode: "sharing.invite.refused", 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 688b6f9eb..5c3694151 100644 --- a/src/app/medications/__tests__/take-all-due-affordance.test.tsx +++ b/src/app/medications/__tests__/take-all-due-affordance.test.tsx @@ -54,6 +54,8 @@ const OWNER = { username: "owner", displayName: "Margarethe", access: "read" as const, + level: "read" as const, + sections: null, canWrite: false, }; @@ -69,7 +71,7 @@ const READ_ONLY: AccountAccess = { }; const WRITABLE: AccountAccess = { accounts: [OWNER], - active: { ...OWNER, access: "write", canWrite: true }, + active: { ...OWNER, access: "write", level: "write", canWrite: true }, canSwitch: true, }; diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index c5102939d..f5a786503 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -37,6 +37,8 @@ const OWNER = { username: "owner", displayName: "Margarethe", access: "read" as const, + level: "read" as const, + sections: null, canWrite: false, }; @@ -52,7 +54,7 @@ const READ_ONLY: AccountAccess = { }; const WRITABLE: AccountAccess = { accounts: [OWNER], - active: { ...OWNER, access: "write", canWrite: true }, + active: { ...OWNER, access: "write", level: "write", canWrite: true }, canSwitch: true, }; diff --git a/src/components/charts/__tests__/chart-overlay-controls.test.tsx b/src/components/charts/__tests__/chart-overlay-controls.test.tsx index 7da076230..15bc0838c 100644 --- a/src/components/charts/__tests__/chart-overlay-controls.test.tsx +++ b/src/components/charts/__tests__/chart-overlay-controls.test.tsx @@ -8,6 +8,8 @@ const OWNER = { username: "owner", displayName: "Margarethe", access: "write" as const, + level: "write" as const, + sections: null, canWrite: true, }; @@ -95,7 +97,12 @@ describe("", () => { for (const access of ["read", "write"] as const) { mockAccessRef.value = { accounts: [OWNER], - active: { ...OWNER, access, canWrite: access === "write" }, + active: { + ...OWNER, + access, + level: access, + canWrite: access === "write", + }, canSwitch: true, }; const html = renderToStaticMarkup( diff --git a/src/components/dashboard/__tests__/looking-after-card.test.tsx b/src/components/dashboard/__tests__/looking-after-card.test.tsx index ebf1a74fe..2358a02d1 100644 --- a/src/components/dashboard/__tests__/looking-after-card.test.tsx +++ b/src/components/dashboard/__tests__/looking-after-card.test.tsx @@ -25,6 +25,8 @@ const GRANDMOTHER = { username: "gran", displayName: "Margarethe", access: "read" as const, + level: "read" as const, + sections: null, canWrite: false, }; const FATHER = { @@ -32,6 +34,8 @@ const FATHER = { username: "dad", displayName: null, access: "write" as const, + level: "write" as const, + sections: null, canWrite: true, }; @@ -114,7 +118,9 @@ describe("", () => { // about it this second. A grant that lapsed, or a level this build does // not honour, must not promise an add the next request would refuse. const html = render({ - accounts: [{ ...FATHER, access: "write", canWrite: false }], + accounts: [ + { ...FATHER, access: "write", level: "write", canWrite: false }, + ], active: null, canSwitch: true, }); 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 f0e112eff..d5f5166ac 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 @@ -115,11 +115,14 @@ const OWNER_READ: AccountAccessEntry = { username: "grandma", displayName: "Margarethe", access: "read" as const, + level: "read" as const, + sections: null, canWrite: false, }; const OWNER_WRITE: AccountAccessEntry = { ...OWNER_READ, access: "write", + level: "write", canWrite: true, }; diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx index 36c589b56..eff5e8653 100644 --- a/src/components/layout/__tests__/shared-record-chrome.test.tsx +++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx @@ -57,6 +57,8 @@ const OWNER = { username: "grandma", displayName: "Margarethe", access: "read" as const, + level: "read" as const, + sections: null, canWrite: false, }; @@ -117,7 +119,12 @@ describe("", () => { it("drops the read-only line when the server says the grant can write", () => { // Resolved server-side. The banner never decides what a grant permits; the // day a WRITE grant exists this line stops claiming otherwise on its own. - const writable = { ...OWNER, access: "write" as const, canWrite: true }; + const writable = { + ...OWNER, + access: "write" as const, + level: "write" as const, + canWrite: true, + }; const html = render( { accounts: [writable], active: writable, canSwitch: true }, , diff --git a/src/components/settings/access/__tests__/access-panel.test.tsx b/src/components/settings/access/__tests__/access-panel.test.tsx index 2c82c6b6d..d1ce4bbf9 100644 --- a/src/components/settings/access/__tests__/access-panel.test.tsx +++ b/src/components/settings/access/__tests__/access-panel.test.tsx @@ -63,6 +63,7 @@ const BASE: GrantRow = { id: "g1", account: { id: "acct-1", username: "housemate", displayName: "Jo" }, access: "READ", + scope: null, state: "ACTIVE", invitedAt: "2026-01-02T10:00:00.000Z", acceptedAt: "2026-01-02T11:00:00.000Z", diff --git a/src/components/settings/access/__tests__/grant-access-level.test.tsx b/src/components/settings/access/__tests__/grant-access-level.test.tsx index daee619ac..3e52124a6 100644 --- a/src/components/settings/access/__tests__/grant-access-level.test.tsx +++ b/src/components/settings/access/__tests__/grant-access-level.test.tsx @@ -65,6 +65,7 @@ const BASE: GrantRow = { id: "g1", account: { id: "acct-1", username: "housemate", displayName: "Jo" }, access: "READ", + scope: null, state: "PENDING", invitedAt: "2026-01-02T10:00:00.000Z", acceptedAt: null, @@ -75,33 +76,57 @@ const BASE: GrantRow = { }; describe("offering a level", () => { - it("offers both levels and preselects the narrower one", () => { + it("offers all three levels and preselects the narrowest", () => { const html = render(); expect(html).toContain('data-slot="grant-invite-access"'); expect(html).toContain('data-access="READ"'); expect(html).toContain('data-access="WRITE"'); + expect(html).toContain('data-access="MANAGE"'); // 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"'); + expect(html).toContain('data-access="MANAGE" data-selected="false"'); }); - it("shows keyboard focus on the option the radio is hidden inside", () => { + it("shows keyboard focus on every option the radio is hidden inside", () => { // The radio is `sr-only`, so the browser's own ring lands on a zero-size // box and tabbing through this fieldset moved an invisible selection — // WCAG 2.4.7, on the screen where somebody grants write access to their // health record. The label wears the ring instead, and `has-[:focus-visible]` // is what carries it from the input to the label. + // + // Both fieldsets are checked, not only the level one: v1.37.0 added a + // second group of radios in the same idiom, and a group that lost the ring + // would be the same defect shipped again one section further down. const html = render(); - const options = html.match( + const levels = html.match( /]*data-slot="grant-invite-access-option"[^>]*>/g, ); - expect(options).toHaveLength(2); - for (const option of options ?? []) { + const scopes = html.match( + /]*data-slot="grant-invite-scope-option"[^>]*>/g, + ); + expect(levels).toHaveLength(3); + expect(scopes).toHaveLength(2); + for (const option of [...(levels ?? []), ...(scopes ?? [])]) { expect(option, option).toContain("has-[:focus-visible]:ring-2"); expect(option, option).toContain("has-[:focus-visible]:ring-ring/50"); } }); + it("says plainly that manage can change and delete what the owner wrote", () => { + const html = render(); + // The clause that separates this level from the one below it. A rewrite + // that keeps "manage" and drops this is a rewrite that stops describing + // a delete button. + expect(html).toContain("add, change and remove entries in your record"); + expect(html).toContain("including ones you entered yourself"); + // And the fence, in the same breath. Consent to management is only + // meaningful beside what management does not reach. + expect(html).toContain("They cannot change your login"); + expect(html).toContain("who else has access"); + expect(html).toContain("You can end this at any time."); + }); + 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. @@ -144,6 +169,24 @@ describe("accepting a level", () => { ); }); + it("asks a manage invitation for its own, heavier consent", () => { + grantsRef.value = { given: [], received: [{ ...BASE, access: "MANAGE" }] }; + const html = render(); + + expect(html).toContain('data-grant-access="MANAGE"'); + // The row states the level in its own words, not the write level's. + expect(html).toContain("change or remove what is in it"); + expect(html).toContain('data-slot="grant-manage-consent"'); + // Not the write sentence wearing a stronger adjective: a different + // responsibility gets a different sentence. + expect(html).not.toContain('data-slot="grant-write-consent"'); + expect(html).toContain("change or remove what is already in it"); + expect(html).toContain("recorded under your name"); + expect(html.indexOf('data-slot="grant-manage-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(); @@ -196,6 +239,21 @@ describe("ending an access, and what it says about what was entered", () => { expect(body).toContain('revokeAttribution({"days":90})'); }); + it("tells a manage owner the same, because the question is sharper there", () => { + // The condition is "not READ" rather than "is WRITE". A manager put things + // in the record AND changed things in it, so "what happens to what they + // did" is the sentence this owner most needs, and a level check that + // listed WRITE by name would have dropped it exactly where it matters. + const body = revokeBody({ + t, + access: "MANAGE", + 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. diff --git a/src/components/settings/access/__tests__/grant-invite-scope.test.tsx b/src/components/settings/access/__tests__/grant-invite-scope.test.tsx new file mode 100644 index 000000000..b66bde634 --- /dev/null +++ b/src/components/settings/access/__tests__/grant-invite-scope.test.tsx @@ -0,0 +1,211 @@ +/** + * The consent surface for a narrowed grant. + * + * What an SSR render can prove and what it cannot, stated up front because it + * shapes every assertion below. It can prove that a control exists, that a + * sentence sits beside it, that a default is the default, and that a level + * that must not offer scope does not render one. It CANNOT prove that the + * sections somebody ticks reach the server: there is no click here, so the + * submit path never runs. That half is `e2e/account-sharing.spec.ts` (which + * reads the posted body) and `src/app/api/account/grants/__tests__/` (which + * reads the written row). + * + * The interactive states are reached by rendering the card with the state + * already in it — React's `useState` initialiser is not reachable from a + * static render, so the expanded state is asserted through the one branch a + * static render CAN see: Manage, whose collapse is unconditional. Everything + * the expanded panel says is pinned in the bundle test at the bottom, which is + * where a dropped honesty sentence would actually be dropped. + * + * Mutation checks, run and recorded in the stream's report: + * - render the eight checkboxes unconditionally (drop the `narrowed` gate) → + * "the entire record is the default and the sections stay closed" goes red; + * - render the scope fieldset for MANAGE too → "manage collapses the whole + * question" goes red. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useInviteGrant: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +import { GrantInviteCard, resolveInviteScope } from "../grant-invite-card"; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +describe("choosing what a grant opens", () => { + it("asks the scope question for the two levels it applies to", () => { + const html = render(); + expect(html).toContain('data-slot="grant-invite-scope"'); + expect(html).toContain('data-scope="all"'); + expect(html).toContain('data-scope="some"'); + }); + + it("makes the entire record the default and leaves the sections closed", () => { + // The shape of the whole design: narrowing is an act somebody chooses, so + // the default costs nothing and the eight checkboxes are not on screen + // until they are wanted. A form that showed them all at once would make + // the common case look like unfinished work. + const html = render(); + expect(html).toContain('data-scope="all" data-selected="true"'); + expect(html).toContain('data-scope="some" data-selected="false"'); + expect(html).not.toContain('data-slot="grant-invite-sections"'); + expect(html).not.toContain('data-slot="grant-invite-section"'); + }); + + it("says nothing about sections before the level is decided", () => { + // The honesty sentences belong to the decision, not to the page. Rendered + // unconditionally they would be a disclaimer somebody scrolls past; they + // appear where the sections do. + const html = render(); + expect(html).not.toContain('data-slot="grant-invite-scope-leak"'); + expect(html).not.toContain('data-slot="grant-invite-scope-aggregates"'); + }); +}); + +describe("the bundle behind the expanded state", () => { + // What the panel says is copy, and copy is where this silently rots. The + // strings are asserted against the shipped English bundle rather than + // against a render, so a key that stops being written is caught here and a + // key that stops being READ is caught by the i18n reverse-coverage guard. + const en = JSON.parse( + readFileSync(join(process.cwd(), "messages/en.json"), "utf8"), + ) as { recordSharing: Record> }; + const invite = en.recordSharing.invite as Record; + const sections = en.recordSharing.section as Record< + string, + { label: string; detail: string } + >; + + it("names all eight sections with a sentence each", () => { + // Eight, not seven and not nine: the vocabulary is closed, and a section + // in the enum without copy would render a blank line on a consent screen. + expect(Object.keys(sections).sort()).toEqual( + [ + "cycle", + "documents", + "illness", + "labs", + "measurements", + "medications", + "mind", + "profile", + ].sort(), + ); + for (const [key, entry] of Object.entries(sections)) { + expect(entry.label.length, key).toBeGreaterThan(2); + // A sentence, not a repeat of the label: the names are the product's + // own boundaries and mean nothing to somebody who did not draw them. + expect(entry.detail.length, key).toBeGreaterThan(entry.label.length); + } + }); + + it("keeps both honest limits, in the words that make them honest", () => { + // Leakage: a section shares everything written inside it, notes included. + expect(invite.scopeLeakNote).toContain( + "Sharing a section shares everything written in it", + ); + // And the aggregates a narrowed grant simply does not get. Saying this + // after the fact is a support ticket; saying it here is consent. + expect(invite.scopeAggregateNote).toContain( + "only appear when the entire record is shared", + ); + }); + + it("warns about the one section whose name does not bound its contents", () => { + expect(invite.scopeDocumentsNote).toContain("can contain anything"); + }); + + it("states the manage fence in the invitation copy itself", () => { + // Not in a tooltip and not in the docs: on the label, where somebody is + // deciding. + expect(invite.accessManageCapability).toContain( + "add, change and remove entries", + ); + expect(invite.accessManageCapability).toContain( + "including ones you entered yourself", + ); + expect(invite.accessManageCapability).toContain("They cannot change your"); + }); +}); + +describe("what the form would send", () => { + // The interactive states a static render cannot reach, exercised through + // the pure function the form itself calls. This is the half that matters: + // the copy above says what a person is agreeing to, this says what leaves + // the browser. + + it("sends nothing scoped for the whole record", () => { + expect( + resolveInviteScope({ access: "READ", narrowed: false, sections: [] }), + ).toEqual({ mode: "all", scope: null, blocked: false }); + }); + + it("sends the picked sections in reading order, not clicking order", () => { + // The state is a click log. Two owners who picked the same three sections + // in a different sequence must write rows that look the same, or a diff of + // two grants means nothing. + const choice = resolveInviteScope({ + access: "WRITE", + narrowed: true, + sections: ["documents", "labs", "measurements"], + }); + expect(choice.mode).toBe("pick"); + expect(choice.scope).toEqual(["measurements", "labs", "documents"]); + expect(choice.blocked).toBe(false); + }); + + it("refuses to send a narrowed invitation with nothing picked", () => { + // A grant that opens nothing is not a narrow grant, it is a mistake + // wearing one. The server refuses it; the form does not ask. + const choice = resolveInviteScope({ + access: "READ", + narrowed: true, + sections: [], + }); + expect(choice.blocked).toBe(true); + expect(choice.scope).toEqual([]); + }); + + it("never sends a scope with manage, whatever is still ticked", () => { + // The property that needs a test rather than a comment. The checkbox state + // survives a trip through the Manage option on purpose — somebody who + // narrows, reads the Manage sentence and goes back should find their + // sections intact — so "not on screen" is NOT "not in state". The level is + // what decides, and it decides here. + const choice = resolveInviteScope({ + access: "MANAGE", + narrowed: true, + sections: ["medications", "labs"], + }); + expect(choice.mode).toBe("manage"); + expect(choice.scope).toBeNull(); + expect(choice.blocked).toBe(false); + }); + + it("carries the sentence that says why manage has no sections", () => { + const en = JSON.parse( + readFileSync(join(process.cwd(), "messages/en.json"), "utf8"), + ) as { recordSharing: { invite: Record } }; + expect(en.recordSharing.invite.scopeManageNote).toBe( + "Manage access always covers the entire record.", + ); + // And the consequence of gating it on a second factor, said where + // somebody meets it rather than in an error message afterwards. + expect(en.recordSharing.invite.manageStepUpNote).toContain("browser"); + }); +}); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index f7c3e718a..568af2816 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -6,33 +6,63 @@ import { UserPlus } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { ApiError } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { useInviteGrant } from "@/lib/queries/use-account-grants"; +import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; /** - * v1.36.0 — offering somebody access to this record. + * v1.37.0 — offering somebody access to this record. * - * 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. + * Three decisions now: who, what they may do, and how much of the record they + * may open. All three are made once, here — a grant is fixed at the level and + * over the sections it was offered with, 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 + * every label on it has 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. + * ## Why this shape and not the obvious one * - * 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 - * part somebody needs to know BEFORE they type a name: nothing has been shared - * yet, and nothing will be until the other side agrees. + * The obvious shape is three levels times eight sections laid out at once: + * nineteen controls, all visible, all equal. It reads as a permissions matrix + * and it makes the common case — "my partner can see everything" — look like + * work somebody has to finish. So the form is two questions asked in order, + * and the second one is a door rather than a grid: + * + * * **What they may do** stays what it was, one radio per level, narrowest + * preselected, each with the sentence that says what it actually does + * rather than the word for it. + * * **What they may open** is two radios, whole record preselected. Narrowing + * is the branch somebody chooses, and only then do the eight sections + * appear. A person who wants the default reads two lines and presses send; + * a person who wants to narrow sees at most eight named subjects with a + * sentence each, which is a thing a person can hold in their head. Nobody + * ever sees nineteen checkboxes. + * + * The two honest limits are stated inside the expanded state, where the + * decision is actually being made, rather than in help text somebody would + * have to go looking for: a section leaks through its own notes and names, and + * the cross-record surfaces (dashboard, score, digest) do not appear at all for + * a narrowed grant. Both are said before the checkboxes, not after them. + * + * Manage collapses the whole second question to one line, because management + * is whole-record by construction. That is a design decision made elsewhere + * (a manager who could edit part of a record could still write anything into + * that part, so a boundary drawn there would be a promise the product cannot + * keep) and this card states its consequence rather than re-deciding it. + * + * ## The Manage sentence + * + * It says the other person can add, change and remove entries, including ones + * the owner entered themselves. That clause is the whole of what separates + * this level from the one below it, and softening it — "manage the record", + * "help keep it up to date" — would be describing a delete button as + * housekeeping. What Manage does NOT reach is said in the same breath, because + * a fence nobody is told about is not reassurance, it is a surprise waiting. * * Errors are named, not generic. "No account with that name" and "that person * already has access" are different situations with different next steps, and @@ -47,22 +77,57 @@ export function GrantInviteCard() { // READ preselected. The narrower level is the one somebody should have to // choose their way out of, not into. const [access, setAccess] = useState("READ"); + // The entire record preselected, for the same reason and with the opposite + // sign: narrowing is the deliberate act, so it is the one that costs a click. + const [narrowed, setNarrowed] = useState(false); + const [sections, setSections] = useState([]); const [error, setError] = useState(null); const [invited, setInvited] = useState(null); + // Cycle tracking is offered as a section only to somebody who tracks it. + // Listing it otherwise would put a subject on the consent screen that has no + // data behind it and, for many accounts, no relevance either. + const tracksCycle = useModuleEnabled("cycle"); + const offered = SHARE_DOMAINS.filter( + (domain) => domain !== "cycle" || tracksCycle, + ); + + const choice = resolveInviteScope({ access, narrowed, sections }); + + const chooseAccess = (level: GrantAccessLevel) => { + setAccess(level); + setError(null); + }; + + const toggleSection = (domain: ShareDomain) => { + setSections((current) => + current.includes(domain) + ? current.filter((d) => d !== domain) + : [...current, domain], + ); + setError(null); + }; + const submit = (event: React.FormEvent) => { event.preventDefault(); const value = identifier.trim(); - if (value.length === 0) return; + if (value.length === 0 || choice.blocked) return; setError(null); setInvited(null); invite.mutate( - { identifier: value, access, expiresAt: endOfDayIso(expiresOn) }, + { + identifier: value, + access, + scope: choice.scope, + expiresAt: endOfDayIso(expiresOn), + }, { onSuccess: (grant) => { setIdentifier(""); setExpiresOn(""); setAccess("READ"); + setNarrowed(false); + setSections([]); setInvited(grant.account.username); }, onError: (err) => setError(t(inviteErrorKey(err))), @@ -106,19 +171,120 @@ export function GrantInviteCard() { setAccess("READ")} + onSelect={() => chooseAccess("READ")} label={t("recordSharing.invite.accessReadLabel")} capability={t("recordSharing.invite.accessReadCapability")} /> setAccess("WRITE")} + onSelect={() => chooseAccess("WRITE")} label={t("recordSharing.invite.accessWriteLabel")} capability={t("recordSharing.invite.accessWriteCapability")} /> + chooseAccess("MANAGE")} + label={t("recordSharing.invite.accessManageLabel")} + capability={t("recordSharing.invite.accessManageCapability")} + /> + {choice.mode === "manage" && ( +

+ {t("recordSharing.invite.manageStepUpNote")} +

+ )} + + {choice.mode === "manage" ? ( + // Not a hidden section and not a disabled one: the question does not + // apply at this level, so what is rendered is the answer to it. +

+ {t("recordSharing.invite.scopeManageNote")} +

+ ) : ( +
+ + {t("recordSharing.invite.scopeLegend")} + +
+ { + setNarrowed(false); + setError(null); + }} + label={t("recordSharing.invite.scopeAllLabel")} + capability={t("recordSharing.invite.scopeAllCapability")} + /> + { + setNarrowed(true); + setError(null); + }} + label={t("recordSharing.invite.scopeSomeLabel")} + capability={t("recordSharing.invite.scopeSomeCapability")} + /> +
+ {choice.mode === "pick" && ( +
+ {/* Both limits are stated ABOVE the checkboxes. A caveat under + a control somebody has already ticked is a disclaimer; the + same sentence above it is part of the decision. */} +
+

+ {t("recordSharing.invite.scopeLeakNote")} +

+

+ {t("recordSharing.invite.scopeAggregateNote")} +

+
+
+ {offered.map((domain) => ( + toggleSection(domain)} + label={t(`recordSharing.section.${domain}.label`)} + detail={t(`recordSharing.section.${domain}.detail`)} + // A document can hold a letter, a scan, a whole file + // somebody else wrote. It is the one section whose name + // does not bound what is inside it, so it says so. + warning={ + domain === "documents" + ? t("recordSharing.invite.scopeDocumentsNote") + : undefined + } + /> + ))} +
+ {choice.blocked && ( +

+ {t("recordSharing.invite.scopeEmptyHint")} +

+ )} +
+ )} +
+ )} +