From c92a80ee4c9bd4459b5e836cbc44a9cf2c749f80 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 23:30:07 +0200
Subject: [PATCH 1/9] feat: describe managed sharing activity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every action a manage-level route files now renders as its own sentence in
the owner's activity view, in all six locales. A write delegate can only add,
so the generic "made a change" was at least the right shape of fact; a manager
changes and removes entries the owner made themselves, and the generic line
withholds the only part that matters — whether a reading was corrected or
deleted, whether a dose history was purged.
The inventory reads the routes and calls the map rather than matching `case`
in its source, so an action that falls through to the fallback fails, and it
follows each resolved key into all six bundles. Every sentence interpolates
the actor's name and nothing else: the audit table is not encrypted and a
value in a verb line would be a health figure in a second store.
The revoke dialog's retention disclosure no longer depends on which of two
requests answers first. `retentionDays` rides the grant list, so a client
holding a row to revoke holds the window it must state, and the branch that
omitted the sentence is gone.
---
docs/api/openapi.yaml | 9 +
messages/de.json | 59 ++-
messages/en.json | 59 ++-
messages/es.json | 59 ++-
messages/fr.json | 59 ++-
messages/it.json | 59 ++-
messages/pl.json | 59 ++-
.../manage-activity-inventory.test.ts | 487 ++++++++++++++++++
src/app/api/account/grants/route.ts | 16 +
.../access/__tests__/access-panel.test.tsx | 42 +-
.../__tests__/grant-access-level.test.tsx | 48 +-
.../settings/access/grants-given-card.tsx | 38 +-
src/lib/openapi/routes/account-sharing.ts | 6 +
src/lib/queries/use-account-grants.ts | 10 +
src/lib/record-activity/activity-verb.ts | 182 ++++++-
15 files changed, 1131 insertions(+), 61 deletions(-)
create mode 100644 src/__tests__/manage-activity-inventory.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index bbd1d6b42..8fbadf170 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -37000,9 +37000,18 @@ components:
type: array
items:
$ref: "#/components/schemas/AccountGrant"
+ retentionDays:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: How long the record's activity feed can still attribute an entry to the person who made it, in days.
+ Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a
+ constant. It rides this payload so a client ending an access can state the window in the same breath,
+ without a second request whose timing would decide whether the owner was told.
required:
- given
- received
+ - retentionDays
additionalProperties: false
description: "Both directions at once: `given` are the grants this account has offered on its own record, `received` are
the ones offered to it. Capped at 100 rows per direction, newest first."
diff --git a/messages/de.json b/messages/de.json
index 2e876a92b..50224cb88 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4461,7 +4461,9 @@
"dateMdy": "Monat / Tag / Jahr",
"dateYmd": "Jahr / Monat / Tag"
},
- "modules": { "title": "Module" },
+ "modules": {
+ "title": "Module"
+ },
"notifications": {
"title": "Benachrichtigungen",
"moodReminder": "Stimmungserinnerung aktivieren",
@@ -9652,7 +9654,60 @@
"medicationSideEffectCreate": "{name} hat eine Nebenwirkung notiert",
"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"
+ "medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen",
+ "measurementUpdate": "{name} hat einen Messwert geändert",
+ "measurementDelete": "{name} hat einen Messwert gelöscht",
+ "measurementDeleteBulk": "{name} hat mehrere Messwerte auf einmal gelöscht",
+ "measurementRestore": "{name} hat einen Messwert wiederhergestellt",
+ "measurementReminderCreate": "{name} hat eine Messerinnerung angelegt",
+ "measurementReminderUpdate": "{name} hat eine Messerinnerung geändert",
+ "measurementReminderDelete": "{name} hat eine Messerinnerung gelöscht",
+ "measurementReminderComplete": "{name} hat eine Messerinnerung erledigt",
+ "measurementReminderSatisfy": "{name} hat eine Messerinnerung abgehakt",
+ "labResultUpdate": "{name} hat einen Laborwert geändert",
+ "labResultDelete": "{name} hat einen Laborwert gelöscht",
+ "labResultRestore": "{name} hat einen Laborwert wiederhergestellt",
+ "biomarkerUpdate": "{name} hat einen Biomarker geändert",
+ "biomarkerDelete": "{name} hat einen Biomarker gelöscht",
+ "allergyUpdate": "{name} hat eine Allergie geändert",
+ "allergyDelete": "{name} hat eine Allergie gelöscht",
+ "familyHistoryUpdate": "{name} hat einen Eintrag zur Familienanamnese geändert",
+ "familyHistoryDelete": "{name} hat einen Eintrag zur Familienanamnese gelöscht",
+ "illnessEpisodeUpdate": "{name} hat einen Krankheitseintrag geändert",
+ "illnessEpisodeDelete": "{name} hat einen Krankheitseintrag gelöscht",
+ "illnessEpisodeResolve": "{name} hat eine Erkrankung als beendet markiert",
+ "illnessEpisodeRestore": "{name} hat einen Krankheitseintrag wiederhergestellt",
+ "illnessDayLogUpsert": "{name} hat einen Krankheitstag erfasst",
+ "medicationUpdate": "{name} hat ein Medikament geändert",
+ "medicationDelete": "{name} hat ein Medikament gelöscht",
+ "medicationGlp1Update": "{name} hat eine GLP-1-Therapie geändert",
+ "medicationScheduleCreate": "{name} hat einen Einnahmeplan angelegt",
+ "medicationScheduleUpdate": "{name} hat einen Einnahmeplan geändert",
+ "medicationScheduleDelete": "{name} hat einen Einnahmeplan gelöscht",
+ "medicationInventoryCreate": "{name} hat eine Packung angelegt",
+ "medicationInventoryUpdate": "{name} hat eine Packung geändert",
+ "medicationInventoryDelete": "{name} hat eine Packung gelöscht",
+ "medicationSideEffectDelete": "{name} hat eine Nebenwirkung gelöscht",
+ "medicationIntakeUpdate": "{name} hat eine Einnahme geändert",
+ "medicationIntakeDelete": "{name} hat eine Einnahme gelöscht",
+ "medicationIntakeBulkDelete": "{name} hat mehrere Einnahmen auf einmal gelöscht",
+ "medicationIntakePurge": "{name} hat die gesamte Einnahmehistorie eines Medikaments gelöscht",
+ "medicationIntakeImport": "{name} hat einen Einnahme-Import für ein Medikament gestartet",
+ "medicationIntakeHistoryImport": "{name} hat einen Import der Einnahmehistorie gestartet",
+ "moodEntryCreate": "{name} hat einen Stimmungseintrag angelegt",
+ "moodEntryUpdate": "{name} hat einen Stimmungseintrag geändert",
+ "moodEntryDelete": "{name} hat einen Stimmungseintrag gelöscht",
+ "moodDeleteBulk": "{name} hat mehrere Stimmungseinträge auf einmal gelöscht",
+ "moodRestore": "{name} hat einen Stimmungseintrag wiederhergestellt",
+ "mentalHealthCreate": "{name} hat ein Fragebogenergebnis erfasst",
+ "cycleDelete": "{name} hat einen Zyklus gelöscht",
+ "cycleDayLogUpsert": "{name} hat einen Zyklustag erfasst",
+ "cycleDayLogUpdate": "{name} hat einen Zyklustag geändert",
+ "cycleDayLogDelete": "{name} hat einen Zyklustag gelöscht",
+ "cyclePeriodBoundary": "{name} hat den Beginn oder das Ende einer Periode geändert",
+ "cycleSymptomCreate": "{name} hat ein Zyklussymptom angelegt",
+ "nutrientWaterWrite": "{name} hat die Trinkmenge erfasst",
+ "healthRecordExport": "{name} hat die Akte exportiert"
},
"section": {
"measurements": {
diff --git a/messages/en.json b/messages/en.json
index 704da23e3..255435074 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4461,7 +4461,9 @@
"dateMdy": "Month / day / year",
"dateYmd": "Year / month / day"
},
- "modules": { "title": "Modules" },
+ "modules": {
+ "title": "Modules"
+ },
"notifications": {
"title": "Notifications",
"moodReminder": "Enable mood reminder",
@@ -9652,7 +9654,60 @@
"medicationSideEffectCreate": "{name} noted a side effect",
"medicationCreate": "{name} added a medication",
"medicationIntake": "{name} marked a dose",
- "medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused"
+ "medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused",
+ "measurementUpdate": "{name} changed a reading",
+ "measurementDelete": "{name} removed a reading",
+ "measurementDeleteBulk": "{name} removed several readings at once",
+ "measurementRestore": "{name} restored a reading",
+ "measurementReminderCreate": "{name} added a measurement reminder",
+ "measurementReminderUpdate": "{name} changed a measurement reminder",
+ "measurementReminderDelete": "{name} removed a measurement reminder",
+ "measurementReminderComplete": "{name} completed a measurement reminder",
+ "measurementReminderSatisfy": "{name} cleared a measurement reminder",
+ "labResultUpdate": "{name} changed a lab result",
+ "labResultDelete": "{name} removed a lab result",
+ "labResultRestore": "{name} restored a lab result",
+ "biomarkerUpdate": "{name} changed a biomarker",
+ "biomarkerDelete": "{name} removed a biomarker",
+ "allergyUpdate": "{name} changed an allergy",
+ "allergyDelete": "{name} removed an allergy",
+ "familyHistoryUpdate": "{name} changed a family history entry",
+ "familyHistoryDelete": "{name} removed a family history entry",
+ "illnessEpisodeUpdate": "{name} changed an illness entry",
+ "illnessEpisodeDelete": "{name} removed an illness entry",
+ "illnessEpisodeResolve": "{name} marked an illness as over",
+ "illnessEpisodeRestore": "{name} restored an illness entry",
+ "illnessDayLogUpsert": "{name} recorded an illness day",
+ "medicationUpdate": "{name} changed a medication",
+ "medicationDelete": "{name} removed a medication",
+ "medicationGlp1Update": "{name} changed a GLP-1 course",
+ "medicationScheduleCreate": "{name} added a dosing schedule",
+ "medicationScheduleUpdate": "{name} changed a dosing schedule",
+ "medicationScheduleDelete": "{name} removed a dosing schedule",
+ "medicationInventoryCreate": "{name} added a medication package",
+ "medicationInventoryUpdate": "{name} changed a medication package",
+ "medicationInventoryDelete": "{name} removed a medication package",
+ "medicationSideEffectDelete": "{name} removed a side effect",
+ "medicationIntakeUpdate": "{name} changed a dose",
+ "medicationIntakeDelete": "{name} removed a dose",
+ "medicationIntakeBulkDelete": "{name} removed several doses at once",
+ "medicationIntakePurge": "{name} deleted the whole dose history of a medication",
+ "medicationIntakeImport": "{name} started a dose import for one medication",
+ "medicationIntakeHistoryImport": "{name} started a dose history import",
+ "moodEntryCreate": "{name} added a mood entry",
+ "moodEntryUpdate": "{name} changed a mood entry",
+ "moodEntryDelete": "{name} removed a mood entry",
+ "moodDeleteBulk": "{name} removed several mood entries at once",
+ "moodRestore": "{name} restored a mood entry",
+ "mentalHealthCreate": "{name} recorded a questionnaire result",
+ "cycleDelete": "{name} removed a cycle",
+ "cycleDayLogUpsert": "{name} recorded a cycle day",
+ "cycleDayLogUpdate": "{name} changed a cycle day",
+ "cycleDayLogDelete": "{name} removed a cycle day",
+ "cyclePeriodBoundary": "{name} changed the start or end of a period",
+ "cycleSymptomCreate": "{name} added a cycle symptom",
+ "nutrientWaterWrite": "{name} recorded water intake",
+ "healthRecordExport": "{name} exported the record"
},
"section": {
"measurements": {
diff --git a/messages/es.json b/messages/es.json
index 13cb2d2cb..a1403afd0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4461,7 +4461,9 @@
"dateMdy": "Mes / día / año",
"dateYmd": "Año / mes / día"
},
- "modules": { "title": "Módulos" },
+ "modules": {
+ "title": "Módulos"
+ },
"notifications": {
"title": "Notificaciones",
"moodReminder": "Activar recordatorio de ánimo",
@@ -9652,7 +9654,60 @@
"medicationSideEffectCreate": "{name} anotó un efecto secundario",
"medicationCreate": "{name} añadió un medicamento",
"medicationIntake": "{name} registró una dosis",
- "medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado"
+ "medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado",
+ "measurementUpdate": "{name} cambió una medición",
+ "measurementDelete": "{name} eliminó una medición",
+ "measurementDeleteBulk": "{name} eliminó varias mediciones a la vez",
+ "measurementRestore": "{name} restauró una medición",
+ "measurementReminderCreate": "{name} añadió un recordatorio de medición",
+ "measurementReminderUpdate": "{name} cambió un recordatorio de medición",
+ "measurementReminderDelete": "{name} eliminó un recordatorio de medición",
+ "measurementReminderComplete": "{name} completó un recordatorio de medición",
+ "measurementReminderSatisfy": "{name} cerró un recordatorio de medición",
+ "labResultUpdate": "{name} cambió un resultado de laboratorio",
+ "labResultDelete": "{name} eliminó un resultado de laboratorio",
+ "labResultRestore": "{name} restauró un resultado de laboratorio",
+ "biomarkerUpdate": "{name} cambió un biomarcador",
+ "biomarkerDelete": "{name} eliminó un biomarcador",
+ "allergyUpdate": "{name} cambió una alergia",
+ "allergyDelete": "{name} eliminó una alergia",
+ "familyHistoryUpdate": "{name} cambió un antecedente familiar",
+ "familyHistoryDelete": "{name} eliminó un antecedente familiar",
+ "illnessEpisodeUpdate": "{name} cambió una enfermedad",
+ "illnessEpisodeDelete": "{name} eliminó una enfermedad",
+ "illnessEpisodeResolve": "{name} marcó una enfermedad como terminada",
+ "illnessEpisodeRestore": "{name} restauró una enfermedad",
+ "illnessDayLogUpsert": "{name} registró un día de enfermedad",
+ "medicationUpdate": "{name} cambió un medicamento",
+ "medicationDelete": "{name} eliminó un medicamento",
+ "medicationGlp1Update": "{name} cambió un tratamiento con GLP-1",
+ "medicationScheduleCreate": "{name} añadió una pauta de dosificación",
+ "medicationScheduleUpdate": "{name} cambió una pauta de dosificación",
+ "medicationScheduleDelete": "{name} eliminó una pauta de dosificación",
+ "medicationInventoryCreate": "{name} añadió un envase de medicamento",
+ "medicationInventoryUpdate": "{name} cambió un envase de medicamento",
+ "medicationInventoryDelete": "{name} eliminó un envase de medicamento",
+ "medicationSideEffectDelete": "{name} eliminó un efecto secundario",
+ "medicationIntakeUpdate": "{name} cambió una dosis",
+ "medicationIntakeDelete": "{name} eliminó una dosis",
+ "medicationIntakeBulkDelete": "{name} eliminó varias dosis a la vez",
+ "medicationIntakePurge": "{name} borró todo el historial de dosis de un medicamento",
+ "medicationIntakeImport": "{name} inició una importación de dosis de un medicamento",
+ "medicationIntakeHistoryImport": "{name} inició una importación del historial de dosis",
+ "moodEntryCreate": "{name} añadió una entrada de ánimo",
+ "moodEntryUpdate": "{name} cambió una entrada de ánimo",
+ "moodEntryDelete": "{name} eliminó una entrada de ánimo",
+ "moodDeleteBulk": "{name} eliminó varias entradas de ánimo a la vez",
+ "moodRestore": "{name} restauró una entrada de ánimo",
+ "mentalHealthCreate": "{name} registró el resultado de un cuestionario",
+ "cycleDelete": "{name} eliminó un ciclo",
+ "cycleDayLogUpsert": "{name} registró un día del ciclo",
+ "cycleDayLogUpdate": "{name} cambió un día del ciclo",
+ "cycleDayLogDelete": "{name} eliminó un día del ciclo",
+ "cyclePeriodBoundary": "{name} cambió el inicio o el final de una regla",
+ "cycleSymptomCreate": "{name} añadió un síntoma del ciclo",
+ "nutrientWaterWrite": "{name} registró la ingesta de agua",
+ "healthRecordExport": "{name} exportó el historial"
},
"section": {
"measurements": {
diff --git a/messages/fr.json b/messages/fr.json
index 0c97286b6..3df592639 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4461,7 +4461,9 @@
"dateMdy": "Mois / jour / année",
"dateYmd": "Année / mois / jour"
},
- "modules": { "title": "Modules" },
+ "modules": {
+ "title": "Modules"
+ },
"notifications": {
"title": "Notifications",
"moodReminder": "Activer le rappel d’humeur",
@@ -9652,7 +9654,60 @@
"medicationSideEffectCreate": "{name} a noté un effet indésirable",
"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é"
+ "medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé",
+ "measurementUpdate": "{name} a modifié une mesure",
+ "measurementDelete": "{name} a supprimé une mesure",
+ "measurementDeleteBulk": "{name} a supprimé plusieurs mesures d'un coup",
+ "measurementRestore": "{name} a restauré une mesure",
+ "measurementReminderCreate": "{name} a ajouté un rappel de mesure",
+ "measurementReminderUpdate": "{name} a modifié un rappel de mesure",
+ "measurementReminderDelete": "{name} a supprimé un rappel de mesure",
+ "measurementReminderComplete": "{name} a terminé un rappel de mesure",
+ "measurementReminderSatisfy": "{name} a clôturé un rappel de mesure",
+ "labResultUpdate": "{name} a modifié un résultat de laboratoire",
+ "labResultDelete": "{name} a supprimé un résultat de laboratoire",
+ "labResultRestore": "{name} a restauré un résultat de laboratoire",
+ "biomarkerUpdate": "{name} a modifié un biomarqueur",
+ "biomarkerDelete": "{name} a supprimé un biomarqueur",
+ "allergyUpdate": "{name} a modifié une allergie",
+ "allergyDelete": "{name} a supprimé une allergie",
+ "familyHistoryUpdate": "{name} a modifié un antécédent familial",
+ "familyHistoryDelete": "{name} a supprimé un antécédent familial",
+ "illnessEpisodeUpdate": "{name} a modifié une maladie",
+ "illnessEpisodeDelete": "{name} a supprimé une maladie",
+ "illnessEpisodeResolve": "{name} a marqué une maladie comme terminée",
+ "illnessEpisodeRestore": "{name} a restauré une maladie",
+ "illnessDayLogUpsert": "{name} a enregistré une journée de maladie",
+ "medicationUpdate": "{name} a modifié un médicament",
+ "medicationDelete": "{name} a supprimé un médicament",
+ "medicationGlp1Update": "{name} a modifié un traitement GLP-1",
+ "medicationScheduleCreate": "{name} a ajouté un schéma de prise",
+ "medicationScheduleUpdate": "{name} a modifié un schéma de prise",
+ "medicationScheduleDelete": "{name} a supprimé un schéma de prise",
+ "medicationInventoryCreate": "{name} a ajouté une boîte de médicament",
+ "medicationInventoryUpdate": "{name} a modifié une boîte de médicament",
+ "medicationInventoryDelete": "{name} a supprimé une boîte de médicament",
+ "medicationSideEffectDelete": "{name} a supprimé un effet indésirable",
+ "medicationIntakeUpdate": "{name} a modifié une prise",
+ "medicationIntakeDelete": "{name} a supprimé une prise",
+ "medicationIntakeBulkDelete": "{name} a supprimé plusieurs prises d'un coup",
+ "medicationIntakePurge": "{name} a effacé tout l'historique des prises d'un médicament",
+ "medicationIntakeImport": "{name} a lancé un import des prises d'un médicament",
+ "medicationIntakeHistoryImport": "{name} a lancé un import de l'historique des prises",
+ "moodEntryCreate": "{name} a ajouté une entrée d'humeur",
+ "moodEntryUpdate": "{name} a modifié une entrée d'humeur",
+ "moodEntryDelete": "{name} a supprimé une entrée d'humeur",
+ "moodDeleteBulk": "{name} a supprimé plusieurs entrées d'humeur d'un coup",
+ "moodRestore": "{name} a restauré une entrée d'humeur",
+ "mentalHealthCreate": "{name} a enregistré le résultat d'un questionnaire",
+ "cycleDelete": "{name} a supprimé un cycle",
+ "cycleDayLogUpsert": "{name} a enregistré un jour du cycle",
+ "cycleDayLogUpdate": "{name} a modifié un jour du cycle",
+ "cycleDayLogDelete": "{name} a supprimé un jour du cycle",
+ "cyclePeriodBoundary": "{name} a modifié le début ou la fin de règles",
+ "cycleSymptomCreate": "{name} a ajouté un symptôme de cycle",
+ "nutrientWaterWrite": "{name} a enregistré la consommation d'eau",
+ "healthRecordExport": "{name} a exporté le dossier"
},
"section": {
"measurements": {
diff --git a/messages/it.json b/messages/it.json
index 9bb80dbf5..9a5f4506e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4461,7 +4461,9 @@
"dateMdy": "Mese / giorno / anno",
"dateYmd": "Anno / mese / giorno"
},
- "modules": { "title": "Moduli" },
+ "modules": {
+ "title": "Moduli"
+ },
"notifications": {
"title": "Notifiche",
"moodReminder": "Attiva promemoria dell’umore",
@@ -9652,7 +9654,60 @@
"medicationSideEffectCreate": "{name} ha annotato un effetto collaterale",
"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"
+ "medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato",
+ "measurementUpdate": "{name} ha modificato una misurazione",
+ "measurementDelete": "{name} ha eliminato una misurazione",
+ "measurementDeleteBulk": "{name} ha eliminato più misurazioni in una volta",
+ "measurementRestore": "{name} ha ripristinato una misurazione",
+ "measurementReminderCreate": "{name} ha aggiunto un promemoria di misurazione",
+ "measurementReminderUpdate": "{name} ha modificato un promemoria di misurazione",
+ "measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione",
+ "measurementReminderComplete": "{name} ha completato un promemoria di misurazione",
+ "measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione",
+ "labResultUpdate": "{name} ha modificato un risultato di laboratorio",
+ "labResultDelete": "{name} ha eliminato un risultato di laboratorio",
+ "labResultRestore": "{name} ha ripristinato un risultato di laboratorio",
+ "biomarkerUpdate": "{name} ha modificato un biomarcatore",
+ "biomarkerDelete": "{name} ha eliminato un biomarcatore",
+ "allergyUpdate": "{name} ha modificato un'allergia",
+ "allergyDelete": "{name} ha eliminato un'allergia",
+ "familyHistoryUpdate": "{name} ha modificato una voce di anamnesi familiare",
+ "familyHistoryDelete": "{name} ha eliminato una voce di anamnesi familiare",
+ "illnessEpisodeUpdate": "{name} ha modificato una malattia",
+ "illnessEpisodeDelete": "{name} ha eliminato una malattia",
+ "illnessEpisodeResolve": "{name} ha segnato una malattia come conclusa",
+ "illnessEpisodeRestore": "{name} ha ripristinato una malattia",
+ "illnessDayLogUpsert": "{name} ha registrato una giornata di malattia",
+ "medicationUpdate": "{name} ha modificato un farmaco",
+ "medicationDelete": "{name} ha eliminato un farmaco",
+ "medicationGlp1Update": "{name} ha modificato una terapia GLP-1",
+ "medicationScheduleCreate": "{name} ha aggiunto uno schema di assunzione",
+ "medicationScheduleUpdate": "{name} ha modificato uno schema di assunzione",
+ "medicationScheduleDelete": "{name} ha eliminato uno schema di assunzione",
+ "medicationInventoryCreate": "{name} ha aggiunto una confezione di farmaco",
+ "medicationInventoryUpdate": "{name} ha modificato una confezione di farmaco",
+ "medicationInventoryDelete": "{name} ha eliminato una confezione di farmaco",
+ "medicationSideEffectDelete": "{name} ha eliminato un effetto collaterale",
+ "medicationIntakeUpdate": "{name} ha modificato una dose",
+ "medicationIntakeDelete": "{name} ha eliminato una dose",
+ "medicationIntakeBulkDelete": "{name} ha eliminato più dosi in una volta",
+ "medicationIntakePurge": "{name} ha cancellato tutto lo storico delle dosi di un farmaco",
+ "medicationIntakeImport": "{name} ha avviato un'importazione delle dosi di un farmaco",
+ "medicationIntakeHistoryImport": "{name} ha avviato un'importazione dello storico delle dosi",
+ "moodEntryCreate": "{name} ha aggiunto una voce dell'umore",
+ "moodEntryUpdate": "{name} ha modificato una voce dell'umore",
+ "moodEntryDelete": "{name} ha eliminato una voce dell'umore",
+ "moodDeleteBulk": "{name} ha eliminato più voci dell'umore in una volta",
+ "moodRestore": "{name} ha ripristinato una voce dell'umore",
+ "mentalHealthCreate": "{name} ha registrato il risultato di un questionario",
+ "cycleDelete": "{name} ha eliminato un ciclo",
+ "cycleDayLogUpsert": "{name} ha registrato un giorno del ciclo",
+ "cycleDayLogUpdate": "{name} ha modificato un giorno del ciclo",
+ "cycleDayLogDelete": "{name} ha eliminato un giorno del ciclo",
+ "cyclePeriodBoundary": "{name} ha modificato l'inizio o la fine di una mestruazione",
+ "cycleSymptomCreate": "{name} ha aggiunto un sintomo del ciclo",
+ "nutrientWaterWrite": "{name} ha registrato l'assunzione di acqua",
+ "healthRecordExport": "{name} ha esportato la cartella"
},
"section": {
"measurements": {
diff --git a/messages/pl.json b/messages/pl.json
index f01fa2c7f..aa0cf4af3 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4461,7 +4461,9 @@
"dateMdy": "Miesiąc / dzień / rok",
"dateYmd": "Rok / miesiąc / dzień"
},
- "modules": { "title": "Moduły" },
+ "modules": {
+ "title": "Moduły"
+ },
"notifications": {
"title": "Powiadomienia",
"moodReminder": "Włącz przypomnienie nastroju",
@@ -9652,7 +9654,60 @@
"medicationSideEffectCreate": "{name} zanotował działanie niepożądane",
"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)"
+ "medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)",
+ "measurementUpdate": "{name} zmienił pomiar",
+ "measurementDelete": "{name} usunął pomiar",
+ "measurementDeleteBulk": "{name} usunął kilka pomiarów naraz",
+ "measurementRestore": "{name} przywrócił pomiar",
+ "measurementReminderCreate": "{name} dodał przypomnienie o pomiarze",
+ "measurementReminderUpdate": "{name} zmienił przypomnienie o pomiarze",
+ "measurementReminderDelete": "{name} usunął przypomnienie o pomiarze",
+ "measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze",
+ "measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze",
+ "labResultUpdate": "{name} zmienił wynik badania",
+ "labResultDelete": "{name} usunął wynik badania",
+ "labResultRestore": "{name} przywrócił wynik badania",
+ "biomarkerUpdate": "{name} zmienił biomarker",
+ "biomarkerDelete": "{name} usunął biomarker",
+ "allergyUpdate": "{name} zmienił alergię",
+ "allergyDelete": "{name} usunął alergię",
+ "familyHistoryUpdate": "{name} zmienił wpis w wywiadzie rodzinnym",
+ "familyHistoryDelete": "{name} usunął wpis w wywiadzie rodzinnym",
+ "illnessEpisodeUpdate": "{name} zmienił wpis o chorobie",
+ "illnessEpisodeDelete": "{name} usunął wpis o chorobie",
+ "illnessEpisodeResolve": "{name} oznaczył chorobę jako zakończoną",
+ "illnessEpisodeRestore": "{name} przywrócił wpis o chorobie",
+ "illnessDayLogUpsert": "{name} zapisał dzień choroby",
+ "medicationUpdate": "{name} zmienił lek",
+ "medicationDelete": "{name} usunął lek",
+ "medicationGlp1Update": "{name} zmienił terapię GLP-1",
+ "medicationScheduleCreate": "{name} dodał schemat dawkowania",
+ "medicationScheduleUpdate": "{name} zmienił schemat dawkowania",
+ "medicationScheduleDelete": "{name} usunął schemat dawkowania",
+ "medicationInventoryCreate": "{name} dodał opakowanie leku",
+ "medicationInventoryUpdate": "{name} zmienił opakowanie leku",
+ "medicationInventoryDelete": "{name} usunął opakowanie leku",
+ "medicationSideEffectDelete": "{name} usunął działanie niepożądane",
+ "medicationIntakeUpdate": "{name} zmienił przyjęcie dawki",
+ "medicationIntakeDelete": "{name} usunął przyjęcie dawki",
+ "medicationIntakeBulkDelete": "{name} usunął kilka przyjęć dawek naraz",
+ "medicationIntakePurge": "{name} skasował całą historię przyjmowania leku",
+ "medicationIntakeImport": "{name} rozpoczął import przyjęć dla jednego leku",
+ "medicationIntakeHistoryImport": "{name} rozpoczął import historii przyjmowania",
+ "moodEntryCreate": "{name} dodał wpis nastroju",
+ "moodEntryUpdate": "{name} zmienił wpis nastroju",
+ "moodEntryDelete": "{name} usunął wpis nastroju",
+ "moodDeleteBulk": "{name} usunął kilka wpisów nastroju naraz",
+ "moodRestore": "{name} przywrócił wpis nastroju",
+ "mentalHealthCreate": "{name} zapisał wynik kwestionariusza",
+ "cycleDelete": "{name} usunął cykl",
+ "cycleDayLogUpsert": "{name} zapisał dzień cyklu",
+ "cycleDayLogUpdate": "{name} zmienił dzień cyklu",
+ "cycleDayLogDelete": "{name} usunął dzień cyklu",
+ "cyclePeriodBoundary": "{name} zmienił początek lub koniec miesiączki",
+ "cycleSymptomCreate": "{name} dodał objaw cyklu",
+ "nutrientWaterWrite": "{name} zapisał ilość wypitej wody",
+ "healthRecordExport": "{name} wyeksportował kartę"
},
"section": {
"measurements": {
diff --git a/src/__tests__/manage-activity-inventory.test.ts b/src/__tests__/manage-activity-inventory.test.ts
new file mode 100644
index 000000000..6dace765f
--- /dev/null
+++ b/src/__tests__/manage-activity-inventory.test.ts
@@ -0,0 +1,487 @@
+import { readFileSync, readdirSync } from "node:fs";
+import { join, relative } from "node:path";
+
+import ts from "typescript";
+import { describe, expect, it } from "vitest";
+
+import { revokeBody } from "@/components/settings/access/grants-given-card";
+import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb";
+
+/**
+ * Every MANAGE-admitted action names itself, in all six locales.
+ *
+ * ## What this asks that the sibling guard does not
+ *
+ * `src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts` asks the
+ * same question of the WRITE surface: a delegable write whose audit action has
+ * no sentence renders as "made a change". MANAGE is where that answer stops
+ * being merely vague and starts being wrong. A WRITE delegate can only add,
+ * and "somebody added something" is at least the right shape of fact. A MANAGE
+ * delegate can change and remove entries the owner made themselves, so the
+ * generic line would tell an owner that something happened in their record
+ * while withholding the only part they need — whether a reading was corrected
+ * or deleted, whether a medication was stopped, whether the dose history was
+ * purged. The level ships under "reconstructable or refused", and a feed that
+ * cannot say which verb ran does not reconstruct anything.
+ *
+ * So the inventory here is the MANAGE half of the same sweep, and it goes two
+ * steps further than its sibling:
+ *
+ * 1. it resolves the sentence by CALLING the map rather than by matching
+ * `case "…"` in its source, because a `case` inside a comment matches and
+ * an action whose arm falls through to the fallback does not;
+ * 2. it follows the resolved key into all six bundles, because a key that
+ * exists only in English renders as its own dotted path to everyone else.
+ *
+ * ## PHI
+ *
+ * Two legs, and both are about the same failure. The activity feed reads out
+ * of the audit table, the audit table is not encrypted, and the temptation
+ * whenever a sentence feels thin is to put the value in it — "{name} changed
+ * your weight from 81 to 78". That is a health figure in a second store with
+ * its own retention and no rotation story. So: every verb line interpolates
+ * `name` and nothing else, in every locale, and the feed's own DTO carries no
+ * `details` field for a future caller to reach for.
+ *
+ * ## Why the counts are of MATCHES
+ *
+ * The verdict is an empty list of unmapped actions, and an empty match set
+ * says exactly the same thing. Every leg therefore pins the size of what it
+ * matched — modules walked, MANAGE modules found, one of them named, actions
+ * read, sentences resolved — so a matcher that quietly stopped matching fails
+ * instead of passing. `describe("the matcher sees what it looks for")` proves
+ * both halves against synthetic modules, including the one shape that silenced
+ * the sibling guard: a resolver call prettier broke across lines.
+ */
+const API = join(process.cwd(), "src/app/api");
+const MESSAGES = join(process.cwd(), "messages");
+const ACTIVITY_ROUTE = join(
+ process.cwd(),
+ "src/app/api/account/activity/route.ts",
+);
+const GRANTS_GIVEN_CARD = join(
+ process.cwd(),
+ "src/components/settings/access/grants-given-card.tsx",
+);
+
+/** The declaration a MANAGE-level route makes. */
+const RECORD_RESOLVER = "requireRecordAuth";
+/** The level this file is the inventory of. */
+const MANAGE = "manage";
+/** What an action with no sentence of its own renders as. */
+const FALLBACK_KEY = "recordSharing.activity.acted";
+/** The six bundles the product ships. */
+const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
+
+/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
+ * `*` skips a leading dot and this tree has dot-directories. */
+function routeFiles(dir: string): string[] {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) out.push(...routeFiles(full));
+ else if (entry.name === "route.ts") out.push(full);
+ }
+ return out;
+}
+
+interface ModuleFacts {
+ /** Does the module pass `"manage"` to the record resolver anywhere. */
+ managesRecord: boolean;
+ /**
+ * The action names of the module's AWAITED `auditLog("…")` calls.
+ *
+ * Awaited on purpose: a fire-and-forget breadcrumb on a validation failure
+ * is not something to narrate to the owner.
+ */
+ awaitedAuditActions: string[];
+}
+
+/**
+ * Read both facts off one parse.
+ *
+ * AST rather than substrings, for the reason the sibling guard learned by
+ * going green while matching nothing: prettier moves a call's argument onto
+ * its own line the moment the call grows a second one, and
+ * `requireRecordAuth("manage"` stops appearing in the source text without
+ * anything about the route changing. The import table resolves a local name
+ * back to its exported one, so an alias and a namespace-qualified call both
+ * count, and a comment naming either symbol counts for nothing.
+ */
+export function moduleFacts(source: string, fileName: string): ModuleFacts {
+ const parsed = ts.createSourceFile(
+ fileName,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS,
+ );
+
+ const aliases = new Map();
+ const namespaces = new Set();
+ parsed.forEachChild((node) => {
+ if (!ts.isImportDeclaration(node)) return;
+ const bindings = node.importClause?.namedBindings;
+ if (!bindings) return;
+ if (ts.isNamespaceImport(bindings)) {
+ namespaces.add(bindings.name.text);
+ return;
+ }
+ for (const spec of bindings.elements) {
+ aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text);
+ }
+ });
+
+ /** The original exported name behind this callee, or null. */
+ const calleeName = (callee: ts.Expression): string | null => {
+ if (ts.isIdentifier(callee)) return aliases.get(callee.text) ?? callee.text;
+ if (
+ ts.isPropertyAccessExpression(callee) &&
+ ts.isIdentifier(callee.expression) &&
+ namespaces.has(callee.expression.text)
+ ) {
+ return callee.name.text;
+ }
+ return null;
+ };
+
+ /** The first argument when it is a plain string literal. */
+ const firstStringArg = (node: ts.CallExpression): string | null => {
+ const first = node.arguments[0];
+ return first && ts.isStringLiteralLike(first) ? first.text : null;
+ };
+
+ let managesRecord = false;
+ const awaitedAuditActions: string[] = [];
+
+ const visit = (node: ts.Node): void => {
+ if (ts.isCallExpression(node)) {
+ const name = calleeName(node.expression);
+ if (name === RECORD_RESOLVER && firstStringArg(node) === MANAGE) {
+ managesRecord = true;
+ }
+ }
+ if (ts.isAwaitExpression(node) && ts.isCallExpression(node.expression)) {
+ const call = node.expression;
+ if (calleeName(call.expression) === "auditLog") {
+ const action = firstStringArg(call);
+ if (action !== null) awaitedAuditActions.push(action);
+ }
+ }
+ node.forEachChild(visit);
+ };
+ parsed.forEachChild(visit);
+
+ return { managesRecord, awaitedAuditActions };
+}
+
+/** The message key one action resolves to, or the fallback. */
+function verbKeyFor(action: string): { key: string; params: string[] } {
+ let key = "";
+ let params: string[] = [];
+ recordActivityVerbLine(
+ (resolved, given) => {
+ key = resolved;
+ params = Object.keys(given ?? {});
+ return resolved;
+ },
+ action,
+ "Alex",
+ );
+ return { key, params };
+}
+
+function bundle(locale: string): Record {
+ return JSON.parse(
+ readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
+ ) as Record;
+}
+
+/** The leaf at a dotted path, or undefined. */
+function leaf(root: Record, key: string): unknown {
+ let node: unknown = root;
+ for (const segment of key.split(".")) {
+ if (typeof node !== "object" || node === null) return undefined;
+ node = (node as Record)[segment];
+ }
+ return node;
+}
+
+/** The `{placeholders}` a translated sentence interpolates. */
+function placeholders(sentence: string): string[] {
+ return [...sentence.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
+}
+
+describe("the matcher sees what it looks for", () => {
+ const REFORMATTED = `
+ import { requireRecordAuth } from "@/lib/api-handler";
+ import { auditLog } from "@/lib/auth/audit";
+
+ export const DELETE = apiHandler(async () => {
+ const { user } = await requireRecordAuth(
+ "manage",
+ "labs",
+ );
+ await auditLog("thing.delete", { userId: user.id });
+ return apiSuccess({ id: user.id });
+ });
+ `;
+
+ it("matches a resolver call broken across lines", () => {
+ // The shape that silenced the sibling guard: the argument moves onto its
+ // own line and a substring test goes quiet with nothing else changed.
+ expect(REFORMATTED.includes('requireRecordAuth("manage"')).toBe(false);
+ const facts = moduleFacts(REFORMATTED, "r.ts");
+ expect(facts.managesRecord).toBe(true);
+ expect(facts.awaitedAuditActions).toEqual(["thing.delete"]);
+ });
+
+ it("matches an aliased and a namespace-qualified resolver call", () => {
+ const ALIASED = `
+ import { requireRecordAuth as resolveRecord } from "@/lib/api-handler";
+ import { auditLog as record } from "@/lib/auth/audit";
+
+ export const DELETE = apiHandler(async () => {
+ const { user } = await resolveRecord("manage", "labs");
+ await record("thing.aliased");
+ return apiSuccess({ id: user.id });
+ });
+ `;
+ expect(moduleFacts(ALIASED, "a.ts")).toEqual({
+ managesRecord: true,
+ awaitedAuditActions: ["thing.aliased"],
+ });
+
+ const NAMESPACED = `
+ import * as auth from "@/lib/api-handler";
+
+ export const DELETE = auth.apiHandler(async () => {
+ const { user } = await auth.requireRecordAuth("manage", "labs");
+ return apiSuccess({ id: user.id });
+ });
+ `;
+ expect(moduleFacts(NAMESPACED, "n.ts").managesRecord).toBe(true);
+ });
+
+ it("does not enrol a write route that only talks about managing", () => {
+ const WRITE_ONLY = `
+ /** Delegable for writes. It must never take requireRecordAuth("manage"). */
+ import { requireRecordAuth } from "@/lib/api-handler";
+
+ const LEVEL = "manage";
+
+ export const POST = apiHandler(async () => {
+ const { user } = await requireRecordAuth("write", "labs");
+ return apiSuccess({ id: user.id, level: LEVEL });
+ });
+ `;
+ // The raw text carries the exact substring a naive membership test uses.
+ expect(WRITE_ONLY.includes('requireRecordAuth("manage")')).toBe(true);
+ expect(moduleFacts(WRITE_ONLY, "wo.ts").managesRecord).toBe(false);
+ });
+
+ it("reads only the awaited audit calls", () => {
+ const MIXED = `
+ import { requireRecordAuth } from "@/lib/api-handler";
+ import { auditLog } from "@/lib/auth/audit";
+
+ export const DELETE = apiHandler(async () => {
+ const { user } = await requireRecordAuth("manage", "labs");
+ void auditLog("thing.breadcrumb", {});
+ await auditLog("thing.delete", { userId: user.id });
+ return apiSuccess({ id: user.id });
+ });
+ `;
+ expect(moduleFacts(MIXED, "m.ts").awaitedAuditActions).toEqual([
+ "thing.delete",
+ ]);
+ });
+
+ it("still reports the fallback for an action nobody mapped", () => {
+ // The positive control for the recording translator below. Without it, a
+ // `verbKeyFor` that always returned the same string would report every
+ // action as specific and this file would prove nothing.
+ expect(verbKeyFor("nothing.anybody.mapped").key).toBe(FALLBACK_KEY);
+ });
+});
+
+describe("every MANAGE action has a sentence of its own", () => {
+ const routes = routeFiles(API);
+ const manageModules = routes
+ .map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) }))
+ .filter((m) => m.managesRecord);
+ const actions = [
+ ...new Set(manageModules.flatMap((m) => m.awaitedAuditActions)),
+ ].sort();
+
+ it("matches the MANAGE modules, and not zero of them", () => {
+ // A walk that finds nothing agrees with an inventory that covers nothing.
+ expect(routes.length).toBeGreaterThan(300);
+ expect(manageModules.length).toBeGreaterThan(0);
+ // Named anchors, so a matcher that collapsed to one stray file fails
+ // rather than clearing the floor. Deleting a reading and purging a dose
+ // history are the two acts the level exists for and the two an owner most
+ // needs named.
+ const found = manageModules.map((m) => relative(process.cwd(), m.file));
+ expect(found).toContain("src/app/api/measurements/[id]/route.ts");
+ expect(found).toContain(
+ "src/app/api/medications/[id]/intake/purge/route.ts",
+ );
+ // And the actions themselves were read, not merely looked for.
+ expect(actions.length).toBeGreaterThan(40);
+ });
+
+ it("gives every one of them a specific verb rather than the fallback", () => {
+ const generic = actions.filter(
+ (action) => verbKeyFor(action).key === FALLBACK_KEY,
+ );
+
+ expect(
+ generic,
+ "a MANAGE action with no verb line renders to the owner as the generic " +
+ "'made a change'. Add a case to activity-verb.ts and a sentence to " +
+ "all six bundles.",
+ ).toEqual([]);
+ });
+
+ it("resolves every verb key in all six bundles", () => {
+ const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
+ const missing: string[] = [];
+ let resolved = 0;
+
+ for (const action of actions) {
+ const { key } = verbKeyFor(action);
+ for (const locale of LOCALES) {
+ const value = leaf(bundles.get(locale)!, key);
+ if (typeof value === "string" && value.length > 0) resolved += 1;
+ else missing.push(`${locale}: ${key}`);
+ }
+ }
+
+ expect(missing.sort()).toEqual([]);
+ // Six locales for every action, and the actions were counted above.
+ expect(resolved).toBe(actions.length * LOCALES.length);
+ });
+
+ it("interpolates the actor's name and nothing else, in every locale", () => {
+ const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
+ const leaky: string[] = [];
+ let checked = 0;
+
+ for (const action of actions) {
+ const { key, params } = verbKeyFor(action);
+ // The call site's own parameters first: a sentence cannot leak a value
+ // the map never hands it.
+ if (params.join(",") !== "name") leaky.push(`${key} passes ${params}`);
+ for (const locale of LOCALES) {
+ const value = leaf(bundles.get(locale)!, key);
+ if (typeof value !== "string") continue;
+ checked += 1;
+ const found = placeholders(value);
+ if (found.length !== 1 || found[0] !== "name") {
+ leaky.push(`${locale}: ${key} interpolates ${found.join(", ")}`);
+ }
+ }
+ }
+
+ expect(leaky.sort()).toEqual([]);
+ expect(checked).toBe(actions.length * LOCALES.length);
+ });
+});
+
+describe("the retention disclosure does not depend on a race", () => {
+ /**
+ * The sentence that bounds attribution — "who entered what is answerable for
+ * the next N days" — used to appear only if a SECOND query had already
+ * answered. The revoke dialog reads the grant list; the number came off the
+ * activity feed. Two independent requests, and whichever lost decided
+ * whether the owner was told. An owner who clicked quickly, or whose
+ * activity read failed, ended a manage grant having read one sentence fewer
+ * than an owner who clicked slowly, with nothing on screen to indicate that
+ * a sentence was missing.
+ *
+ * The number now rides the same payload as the row: if there is a grant to
+ * revoke, `retentionDays` is already in hand. Deterministic means there is
+ * no branch left, which is what both legs below check from opposite sides —
+ * one that the sentence is always composed, one that the second query is
+ * gone.
+ */
+ const t = (key: string, params?: Record) =>
+ params ? `${key}(${JSON.stringify(params)})` : key;
+
+ it("states the window on every grant that could have written something", () => {
+ for (const access of ["WRITE", "MANAGE"] as const) {
+ const body = revokeBody({ t, access, name: "Alex", retentionDays: 90 });
+ expect(body).toContain("recordSharing.given.revokeEntriesStay");
+ expect(body).toContain(
+ 'recordSharing.given.revokeAttribution({"days":90})',
+ );
+ }
+ // READ keeps neither: a read grant never put anything in the record, so
+ // saying what happens to what they entered would describe an act that
+ // cannot have occurred.
+ const read = revokeBody({
+ t,
+ access: "READ",
+ name: "Alex",
+ retentionDays: 90,
+ });
+ expect(read).not.toContain("recordSharing.given.revokeAttribution");
+ });
+
+ it("takes the number from the read that produced the row", () => {
+ const source = readFileSync(GRANTS_GIVEN_CARD, "utf8");
+ // Positive control: the matcher can see a hook name in this file at all.
+ expect(source.match(/useAccountGrants/g)?.length ?? 0).toBeGreaterThan(0);
+ // And the second query, whose arrival used to decide the copy, is gone.
+ expect(source).not.toContain("useRecordActivity");
+ // The branch it fed is gone with it. A required number cannot be absent,
+ // so there is nothing left for a caller to omit — which is the difference
+ // between "the sentence is usually there" and "there is no path without
+ // it". Both halves are named: the optional marker and the guard.
+ expect(source).not.toContain("retentionDays?:");
+ expect(source).not.toContain("retentionDays !== undefined");
+ expect(source.match(/retentionDays: number/g)?.length ?? 0).toBe(1);
+ });
+});
+
+describe("the activity feed carries no audit detail", () => {
+ /**
+ * The DTO the owner's feed publishes, read off the route.
+ *
+ * A field-level assertion rather than a sentence in a comment, because the
+ * pressure to widen it is real and constant: every thin sentence in the feed
+ * looks like it wants the value that would make it specific, and the audit
+ * row usually has one. `details` is a free-form JSON column written by sixty
+ * handlers; publishing it would put whatever any of them decided to file in
+ * front of the record owner, and some of them file a label.
+ */
+ it("publishes five fields, and `details` is not one of them", () => {
+ const source = readFileSync(ACTIVITY_ROUTE, "utf8");
+ const parsed = ts.createSourceFile(
+ ACTIVITY_ROUTE,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS,
+ );
+
+ let members: string[] | null = null;
+ parsed.forEachChild((node) => {
+ if (!ts.isInterfaceDeclaration(node)) return;
+ if (node.name.text !== "RecordActivityEntry") return;
+ members = node.members
+ .map((m) => (m.name && ts.isIdentifier(m.name) ? m.name.text : ""))
+ .filter((n) => n.length > 0)
+ .sort();
+ });
+
+ // The interface was found at all. `toEqual` against null would report the
+ // absence as a mismatch of the list, which reads like the wrong failure.
+ expect(
+ members,
+ "RecordActivityEntry not found in the route",
+ ).not.toBeNull();
+ expect(members).toEqual(["accesses", "action", "actor", "createdAt", "id"]);
+ });
+});
diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts
index 0d2dc86f8..1a0184f55 100644
--- a/src/app/api/account/grants/route.ts
+++ b/src/app/api/account/grants/route.ts
@@ -56,6 +56,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
+import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
@@ -102,6 +103,21 @@ export const GET = apiHandler(async () => {
return apiSuccess({
given: given.map((g) => toGrantView(g, g.grantee, now)),
received: received.map((g) => toGrantView(g, g.grantor, now)),
+ // v1.37.0 — the audit window, on the payload that carries the rows.
+ //
+ // The revoke dialog tells the owner how long "who entered what" stays
+ // answerable after the access ends, and it can only say that with the
+ // number the operator configured. It used to read that number off the
+ // activity feed, a second and independent request, so whether the sentence
+ // appeared depended on which of two reads finished first — and an owner
+ // who clicked quickly ended a manage grant having been told less than one
+ // who clicked slowly, with nothing on screen to say a sentence was
+ // missing. Riding it here removes the race by construction: a client
+ // holding a grant to revoke is holding the window it must disclose.
+ //
+ // Same resolver as `/api/account/activity` and as the purge job, so the
+ // two surfaces cannot state different numbers.
+ retentionDays: getAuditLogRetentionDays(),
});
});
diff --git a/src/components/settings/access/__tests__/access-panel.test.tsx b/src/components/settings/access/__tests__/access-panel.test.tsx
index d1ce4bbf9..42147f829 100644
--- a/src/components/settings/access/__tests__/access-panel.test.tsx
+++ b/src/components/settings/access/__tests__/access-panel.test.tsx
@@ -19,7 +19,19 @@ import type {
RecordActivity,
} from "@/lib/queries/use-account-grants";
-const grantsRef: { value: GrantList } = { value: { given: [], received: [] } };
+const grantsRef: { value: GrantList } = { value: grantList() };
+/**
+ * A grant payload, with the audit window every real one carries.
+ *
+ * v1.37.0 — `retentionDays` rides `GET /api/account/grants` so the revoke
+ * dialog can state how long attribution survives without waiting on a second
+ * request. A fixture that omitted it would be describing a payload the server
+ * cannot send.
+ */
+function grantList(partial: Partial = {}): GrantList {
+ return { given: [], received: [], retentionDays: 365, ...partial };
+}
+
const activityRef: { value: RecordActivity | undefined } = { value: undefined };
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
@@ -75,7 +87,7 @@ const BASE: GrantRow = {
describe("who can open my record", () => {
it("says so honestly when nobody can", () => {
- grantsRef.value = { given: [], received: [] };
+ grantsRef.value = grantList();
const html = render();
expect(html).toContain("Nobody else can open your record");
expect(html).not.toContain('data-slot="grants-given-list"');
@@ -86,7 +98,7 @@ describe("who can open my record", () => {
// produced. A panel that compared `expiresAt` to the clock would be a
// second opinion about somebody's access to a health record, and the two
// would disagree first in the dangerous direction.
- grantsRef.value = {
+ grantsRef.value = grantList({
given: [
{ ...BASE, id: "a", state: "ACTIVE" },
{ ...BASE, id: "p", state: "PENDING", acceptedAt: null },
@@ -98,7 +110,7 @@ describe("who can open my record", () => {
},
],
received: [],
- };
+ });
const html = render();
expect(html).toContain('data-grant-state="ACTIVE"');
expect(html).toContain('data-grant-state="PENDING"');
@@ -109,7 +121,7 @@ describe("who can open my record", () => {
// The row IS the consent record. "Who had access, from when to when, and
// who ended it" is the question the panel exists to answer, and a list
// that dropped the ended rows could not answer it.
- grantsRef.value = {
+ grantsRef.value = grantList({
given: [
{
...BASE,
@@ -119,14 +131,14 @@ describe("who can open my record", () => {
},
],
received: [],
- };
+ });
const html = render();
expect(html).toContain('data-grant-state="REVOKED"');
expect(html).toContain("You ended it");
});
it("distinguishes a withdrawal from a handover", () => {
- grantsRef.value = {
+ grantsRef.value = grantList({
given: [
{
...BASE,
@@ -136,7 +148,7 @@ describe("who can open my record", () => {
},
],
received: [],
- };
+ });
const html = render();
// Two facts, one column. A trail that conflated them would misread an
// ordinary handover as a falling-out.
@@ -145,20 +157,20 @@ describe("who can open my record", () => {
});
it("offers no way to end a grant that has already ended", () => {
- grantsRef.value = {
+ grantsRef.value = grantList({
given: [{ ...BASE, state: "REVOKED", revokedAt: "2026-02-01T09:00:00Z" }],
received: [],
- };
+ });
const ended = render();
expect(ended).not.toContain('data-slot="grant-revoke"');
- grantsRef.value = { given: [BASE], received: [] };
+ grantsRef.value = grantList({ given: [BASE] });
const live = render();
expect(live).toContain('data-slot="grant-revoke"');
});
it("says a live grant has never been used rather than leaving a blank", () => {
- grantsRef.value = { given: [{ ...BASE, lastUsedAt: null }], received: [] };
+ grantsRef.value = grantList({ given: [{ ...BASE, lastUsedAt: null }] });
const html = render();
expect(html).toContain("never");
});
@@ -166,10 +178,10 @@ describe("who can open my record", () => {
describe("records I can open", () => {
it("offers acceptance on an invitation and nothing else", () => {
- grantsRef.value = {
+ grantsRef.value = grantList({
given: [],
received: [{ ...BASE, state: "PENDING", acceptedAt: null }],
- };
+ });
const html = render();
expect(html).toContain('data-slot="grant-accept"');
// Nothing to open and nothing to hand back: an invitation confers nothing.
@@ -178,7 +190,7 @@ describe("records I can open", () => {
});
it("offers opening and handing back once it is active", () => {
- grantsRef.value = { given: [], received: [BASE] };
+ grantsRef.value = grantList({ received: [BASE] });
const html = render();
expect(html).toContain('data-slot="grant-open"');
expect(html).toContain('data-account-id="acct-1"');
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 3e52124a6..d8ee6185a 100644
--- a/src/components/settings/access/__tests__/grant-access-level.test.tsx
+++ b/src/components/settings/access/__tests__/grant-access-level.test.tsx
@@ -21,7 +21,18 @@ 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: [] } };
+const grantsRef: { value: GrantList } = { value: grantList() };
+/**
+ * A grant payload, with the audit window every real one carries.
+ *
+ * v1.37.0 — `retentionDays` rides `GET /api/account/grants` so the revoke
+ * dialog can state how long attribution survives without waiting on a second
+ * request. A fixture that omitted it would be describing a payload the server
+ * cannot send.
+ */
+function grantList(partial: Partial = {}): GrantList {
+ return { given: [], received: [], retentionDays: 365, ...partial };
+}
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
const actual =
@@ -154,10 +165,10 @@ describe("offering a level", () => {
describe("accepting a level", () => {
it("states the level and what accepting it means before the button", () => {
- grantsRef.value = {
+ grantsRef.value = grantList({
given: [],
received: [{ ...BASE, access: "WRITE" }],
- };
+ });
const html = render();
expect(html).toContain('data-grant-access="WRITE"');
@@ -170,7 +181,7 @@ describe("accepting a level", () => {
});
it("asks a manage invitation for its own, heavier consent", () => {
- grantsRef.value = { given: [], received: [{ ...BASE, access: "MANAGE" }] };
+ grantsRef.value = grantList({ received: [{ ...BASE, access: "MANAGE" }] });
const html = render();
expect(html).toContain('data-grant-access="MANAGE"');
@@ -188,7 +199,7 @@ describe("accepting a level", () => {
});
it("does not ask a read invitation for a write consent", () => {
- grantsRef.value = { given: [], received: [{ ...BASE, access: "READ" }] };
+ grantsRef.value = grantList({ received: [{ ...BASE, access: "READ" }] });
const html = render();
expect(html).toContain('data-grant-access="READ"');
expect(html).not.toContain('data-slot="grant-write-consent"');
@@ -196,7 +207,7 @@ describe("accepting a level", () => {
it("stops saying it once the invitation has been answered", () => {
// A sentence about a decision already taken is nagging, not consent.
- grantsRef.value = {
+ grantsRef.value = grantList({
given: [],
received: [
{
@@ -206,7 +217,7 @@ describe("accepting a level", () => {
acceptedAt: "2026-01-02T11:00:00.000Z",
},
],
- };
+ });
const html = render();
expect(html).not.toContain('data-slot="grant-write-consent"');
});
@@ -254,11 +265,24 @@ describe("ending an access, and what it says about what was entered", () => {
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" });
+ it("states the operator's window rather than a compiled-in one", () => {
+ // The number is the operator's setting, so an instance running 90 days
+ // says 90 and this file would fail on a hardcoded 365.
+ //
+ // v1.37.0 replaced the case that used to live here. It asserted that the
+ // sentence was OMITTED when the number had not arrived, which was true and
+ // was the defect: the number came off a second query, so whether an owner
+ // was told how long attribution survives depended on which of two requests
+ // landed first. The window rides the grant list now — a caller with a row
+ // to revoke has it — so there is no absent case left to assert.
+ const body = revokeBody({
+ t,
+ access: "WRITE",
+ name: "Jo",
+ retentionDays: 7,
+ });
expect(body).toContain("recordSharing.given.revokeEntriesStay");
- expect(body).not.toContain("revokeAttribution");
+ expect(body).toContain('revokeAttribution({"days":7})');
+ expect(body).not.toContain("365");
});
});
diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx
index ae9487fb2..f32e0c5d1 100644
--- a/src/components/settings/access/grants-given-card.tsx
+++ b/src/components/settings/access/grants-given-card.tsx
@@ -12,7 +12,6 @@ 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";
@@ -39,12 +38,6 @@ 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,6 +88,10 @@ export function GrantsGivenCard() {
// Only a live grant can be ended. An already-ended row keeps its history
// and loses its button, rather than offering an act that would 409.
if (grant.state !== "ACTIVE" && grant.state !== "PENDING") return null;
+ // The row and the window arrive together. A grant that can be revoked came
+ // out of a resolved payload, so there is no state in which this control
+ // exists and the number behind its disclosure does not.
+ if (!data) return null;
const name = accountLabel(grant.account);
return (
) => string;
access: GrantRow["access"];
name: string;
- /** Undefined until the server has said what the window is. */
- retentionDays?: number;
+ /** The audit window, off the same payload the row came from. */
+ retentionDays: number;
}): string {
const lines = [t("recordSharing.given.revokeBody", { name })];
if (access !== "READ") {
lines.push(t("recordSharing.given.revokeEntriesStay"));
- if (retentionDays !== undefined) {
- lines.push(
- t("recordSharing.given.revokeAttribution", { days: retentionDays }),
- );
- }
+ lines.push(
+ t("recordSharing.given.revokeAttribution", { days: retentionDays }),
+ );
}
return lines.join(" ");
}
diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts
index cb3decceb..41a4d2fb7 100644
--- a/src/lib/openapi/routes/account-sharing.ts
+++ b/src/lib/openapi/routes/account-sharing.ts
@@ -111,6 +111,12 @@ const grantListResponse = z
.object({
given: z.array(grantView),
received: z.array(grantView),
+ retentionDays: z
+ .number()
+ .int()
+ .describe(
+ "How long the record's activity feed can still attribute an entry to the person who made it, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant. It rides this payload so a client ending an access can state the window in the same breath, without a second request whose timing would decide whether the owner was told.",
+ ),
})
.meta({
id: "AccountGrantList",
diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts
index 7fff2b502..2d7cd6fb9 100644
--- a/src/lib/queries/use-account-grants.ts
+++ b/src/lib/queries/use-account-grants.ts
@@ -65,6 +65,16 @@ export interface GrantList {
given: GrantRow[];
/** Grants offered to this account on somebody else's. */
received: GrantRow[];
+ /**
+ * How long the activity feed can still say who entered what, in days —
+ * resolved by the server from the same setting the purge job reads.
+ *
+ * On this payload rather than fetched beside it, because the revoke dialog
+ * has to disclose the window and a client holding a grant to revoke is
+ * already holding the answer. Reading it off a second request made the
+ * sentence appear or not depending on which read landed first.
+ */
+ retentionDays: number;
}
export interface RecordActivityEntry {
diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts
index fac5538f5..d4c78eab0 100644
--- a/src/lib/record-activity/activity-verb.ts
+++ b/src/lib/record-activity/activity-verb.ts
@@ -16,10 +16,34 @@ type Translator = (
* mark a dose taken — and "made a change" for seven different things is a way
* of answering nothing. An owner reading this at breakfast wants the verb.
*
- * The map keeps a line for allergies, family history and a tracked value even
- * though a delegate can no longer add any of the three: rows written while
- * they were admitted sit in the same table and still have to render as
- * something, which is the same reason the fallback below exists.
+ * The map keeps a line for a tracked value even though a delegate can no
+ * longer add one: rows written while it was admitted sit in the same table and
+ * still have to render as something, which is the same reason the fallback
+ * below exists.
+ *
+ * ## v1.37.0 — the MANAGE half
+ *
+ * A WRITE delegate can only add, so the generic line was at least the right
+ * shape of fact. A MANAGE delegate changes and removes entries the owner made
+ * themselves, and there "somebody made a change in your record" withholds the
+ * only part that matters: whether a reading was corrected or deleted, whether
+ * a medication was stopped, whether a whole dose history was purged. The level
+ * ships under "reconstructable or refused", and a feed that cannot say which
+ * verb ran reconstructs nothing.
+ *
+ * So every action a MANAGE-declared route files has an arm below, and
+ * `src/__tests__/manage-activity-inventory.test.ts` reads the routes to prove
+ * it — by CALLING this function, not by matching `case` in its source, so an
+ * action that quietly falls through to the fallback fails there.
+ *
+ * ## What a line may say
+ *
+ * The verb and the kind of thing, never the value. Every sentence here takes
+ * `name` and nothing else, and the inventory guard holds that across all six
+ * bundles. The activity feed reads out of the audit table, which is not
+ * encrypted and has its own retention: "changed your weight from 81 to 78"
+ * would put a health figure in a second store with no rotation story. What
+ * changed is a question the record itself answers.
*
* ## Why unknown actions fall back rather than fail
*
@@ -82,6 +106,156 @@ export function recordActivityVerbLine(
// client sent them together.
case "measurement.create.batch":
return t("recordSharing.activityVerb.measurementCreate", { name });
+
+ /* ---------------------------------------------------------------- */
+ /* v1.37.0 — MANAGE. Everything below can change or remove something */
+ /* the owner put there themselves. */
+ /* ---------------------------------------------------------------- */
+
+ case "measurement.update":
+ return t("recordSharing.activityVerb.measurementUpdate", { name });
+ case "measurement.delete":
+ return t("recordSharing.activityVerb.measurementDelete", { name });
+ // Its own line rather than the singular one. "Removed a reading" for a
+ // bulk delete is the sentence that would let a month of readings go
+ // missing while the feed reported a tidy-up.
+ case "measurement.delete.bulk":
+ return t("recordSharing.activityVerb.measurementDeleteBulk", { name });
+ case "measurement.restore":
+ return t("recordSharing.activityVerb.measurementRestore", { name });
+ case "measurementReminder.create":
+ return t("recordSharing.activityVerb.measurementReminderCreate", {
+ name,
+ });
+ case "measurementReminder.update":
+ return t("recordSharing.activityVerb.measurementReminderUpdate", {
+ name,
+ });
+ case "measurementReminder.delete":
+ return t("recordSharing.activityVerb.measurementReminderDelete", {
+ name,
+ });
+ case "measurementReminder.complete":
+ return t("recordSharing.activityVerb.measurementReminderComplete", {
+ name,
+ });
+ case "measurementReminder.satisfy":
+ return t("recordSharing.activityVerb.measurementReminderSatisfy", {
+ name,
+ });
+
+ case "labResult.update":
+ return t("recordSharing.activityVerb.labResultUpdate", { name });
+ case "labResult.delete":
+ return t("recordSharing.activityVerb.labResultDelete", { name });
+ case "labResult.restore":
+ return t("recordSharing.activityVerb.labResultRestore", { name });
+ case "biomarker.update":
+ return t("recordSharing.activityVerb.biomarkerUpdate", { name });
+ case "biomarker.delete":
+ return t("recordSharing.activityVerb.biomarkerDelete", { name });
+
+ case "allergy.update":
+ return t("recordSharing.activityVerb.allergyUpdate", { name });
+ case "allergy.delete":
+ return t("recordSharing.activityVerb.allergyDelete", { name });
+ case "family-history.update":
+ return t("recordSharing.activityVerb.familyHistoryUpdate", { name });
+ case "family-history.delete":
+ return t("recordSharing.activityVerb.familyHistoryDelete", { name });
+
+ case "illness.episode.update":
+ return t("recordSharing.activityVerb.illnessEpisodeUpdate", { name });
+ case "illness.episode.delete":
+ return t("recordSharing.activityVerb.illnessEpisodeDelete", { name });
+ case "illness.episode.resolve":
+ return t("recordSharing.activityVerb.illnessEpisodeResolve", { name });
+ case "illness.episode.restore":
+ return t("recordSharing.activityVerb.illnessEpisodeRestore", { name });
+ case "illness.day-log.upsert":
+ return t("recordSharing.activityVerb.illnessDayLogUpsert", { name });
+
+ case "medication.update":
+ return t("recordSharing.activityVerb.medicationUpdate", { name });
+ case "medication.delete":
+ return t("recordSharing.activityVerb.medicationDelete", { name });
+ case "medication.glp1.update":
+ return t("recordSharing.activityVerb.medicationGlp1Update", { name });
+ case "medication.schedule_revision.created":
+ return t("recordSharing.activityVerb.medicationScheduleCreate", { name });
+ case "medication.schedule_revision.updated":
+ return t("recordSharing.activityVerb.medicationScheduleUpdate", { name });
+ case "medication.schedule_revision.deleted":
+ return t("recordSharing.activityVerb.medicationScheduleDelete", { name });
+ case "medication.inventory.create":
+ return t("recordSharing.activityVerb.medicationInventoryCreate", {
+ name,
+ });
+ case "medication.inventory.update":
+ return t("recordSharing.activityVerb.medicationInventoryUpdate", {
+ name,
+ });
+ case "medication.inventory.delete":
+ return t("recordSharing.activityVerb.medicationInventoryDelete", {
+ name,
+ });
+ case "medication.sideEffect.delete":
+ return t("recordSharing.activityVerb.medicationSideEffectDelete", {
+ name,
+ });
+ case "medication.intake.update":
+ return t("recordSharing.activityVerb.medicationIntakeUpdate", { name });
+ case "medication.intake.delete":
+ return t("recordSharing.activityVerb.medicationIntakeDelete", { name });
+ case "medication.intake.bulk_delete":
+ return t("recordSharing.activityVerb.medicationIntakeBulkDelete", {
+ name,
+ });
+ // The one that empties a medication's whole history in one request. It
+ // says so, because "removed several doses" would read as a correction.
+ case "medication.intake.purge":
+ return t("recordSharing.activityVerb.medicationIntakePurge", { name });
+ case "medication.intake.import.kickoff":
+ return t("recordSharing.activityVerb.medicationIntakeImport", { name });
+ case "medication.intake.history.import.kickoff":
+ return t("recordSharing.activityVerb.medicationIntakeHistoryImport", {
+ name,
+ });
+
+ case "moodEntry.create":
+ return t("recordSharing.activityVerb.moodEntryCreate", { name });
+ case "moodEntry.update":
+ return t("recordSharing.activityVerb.moodEntryUpdate", { name });
+ case "moodEntry.delete":
+ return t("recordSharing.activityVerb.moodEntryDelete", { name });
+ case "mood.delete.bulk":
+ return t("recordSharing.activityVerb.moodDeleteBulk", { name });
+ case "mood.restore":
+ return t("recordSharing.activityVerb.moodRestore", { name });
+ case "mental-health.create":
+ return t("recordSharing.activityVerb.mentalHealthCreate", { name });
+
+ case "cycle.cycle.delete":
+ return t("recordSharing.activityVerb.cycleDelete", { name });
+ case "cycle.day-log.upsert":
+ return t("recordSharing.activityVerb.cycleDayLogUpsert", { name });
+ case "cycle.day-log.update":
+ return t("recordSharing.activityVerb.cycleDayLogUpdate", { name });
+ case "cycle.day-log.delete":
+ return t("recordSharing.activityVerb.cycleDayLogDelete", { name });
+ case "cycle.period.boundary":
+ return t("recordSharing.activityVerb.cyclePeriodBoundary", { name });
+ case "cycle.symptom.custom.create":
+ return t("recordSharing.activityVerb.cycleSymptomCreate", { name });
+
+ case "nutrient.water.write":
+ return t("recordSharing.activityVerb.nutrientWaterWrite", { name });
+
+ // Not a change to the record, and the one act on this list that takes a
+ // copy of all of it out of the instance. Named plainly for that reason.
+ case "health-record.export":
+ return t("recordSharing.activityVerb.healthRecordExport", { name });
+
default:
return t("recordSharing.activity.acted", { name });
}
From 71ad8d66b6e5a7bf172e66311991a3f10da3b5eb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 23:39:39 +0200
Subject: [PATCH 2/9] fix: preserve sharing failure states
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Accepting, revoking and handing back a grant had no failure rendering at all.
The request came back refused, or never left the machine, and the panel did
nothing: the confirm dialog had already closed, the list did not change, and
the only signal was the absence of one. The two refusals that most need a
reader were the two that arrived most quietly — an invitation that had lapsed
before it was opened, and the last guardian being told they may not hand back
a profile that would then have nobody looking after it.
Each refusal now renders on the row it belongs to, announced, in content
contrast, beside a control that runs the same act again. The sentences are
split by what the person does next rather than by status code, so lapsed and
withdrawn read differently and the guardian refusal names the way out. A
request that never reached the server says so instead of reporting a refusal
nobody issued.
The failed state is read off the mutation rather than copied into local state
from onError: the copy is what goes stale, and the mutation already knows
which row it was for. The invitation form keeps every field it had, and only
a successful send clears them.
---
messages/de.json | 18 +-
messages/en.json | 18 +-
messages/es.json | 18 +-
messages/fr.json | 18 +-
messages/it.json | 18 +-
messages/pl.json | 18 +-
.../sharing-recoverable-errors.test.tsx | 416 ++++++++++++++++++
.../settings/access/grant-action-error.tsx | 143 ++++++
.../settings/access/grant-invite-card.tsx | 11 +-
.../settings/access/grants-given-card.tsx | 27 ++
.../settings/access/grants-received-card.tsx | 70 ++-
11 files changed, 765 insertions(+), 10 deletions(-)
create mode 100644 src/__tests__/sharing-recoverable-errors.test.tsx
create mode 100644 src/components/settings/access/grant-action-error.tsx
diff --git a/messages/de.json b/messages/de.json
index 50224cb88..028a6958a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -9585,7 +9585,8 @@
"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."
+ "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
+ "errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier — prüfe deine Verbindung und versuche es erneut."
},
"given": {
"title": "Wer meine Akte öffnen kann",
@@ -9742,6 +9743,21 @@
"label": "Dokumente",
"detail": "Alles, was im Dokumententresor liegt."
}
+ },
+ "actionError": {
+ "retry": "Erneut versuchen",
+ "offline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.",
+ "rateLimited": "Zu viele Versuche hintereinander. Warte eine Minute und versuche es erneut.",
+ "acceptExpired": "Diese Einladung ist abgelaufen. Bitte um eine neue.",
+ "acceptWithdrawn": "Diese Einladung wurde zurückgezogen.",
+ "acceptNotPending": "Diese Einladung wurde bereits angenommen.",
+ "acceptNotFound": "Diese Einladung gibt es nicht mehr.",
+ "acceptFailed": "Die Einladung konnte nicht angenommen werden.",
+ "lastGuardian": "Diese Person kümmert sich als Einzige um dieses Profil, deshalb kann der Zugriff noch nicht enden. Füge zuerst eine weitere betreuende Person hinzu oder lösche das Profil.",
+ "alreadyEnded": "Dieser Zugriff ist bereits beendet.",
+ "notFound": "Diesen Zugriff gibt es nicht mehr.",
+ "revokeFailed": "Der Zugriff konnte nicht beendet werden.",
+ "renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden."
}
}
}
diff --git a/messages/en.json b/messages/en.json
index 255435074..ce085b6b8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -9585,7 +9585,8 @@
"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."
+ "errorStepUp": "Confirm your second factor, then try again.",
+ "errorOffline": "That did not reach the server. Your invitation is still here — check your connection and try again."
},
"given": {
"title": "Who can open my record",
@@ -9742,6 +9743,21 @@
"label": "Documents",
"detail": "Everything stored in the document vault."
}
+ },
+ "actionError": {
+ "retry": "Try again",
+ "offline": "That did not reach the server. Nothing has changed — check your connection and try again.",
+ "rateLimited": "Too many attempts in a row. Wait a minute and try again.",
+ "acceptExpired": "That invitation has lapsed. Ask for a new one.",
+ "acceptWithdrawn": "That invitation was withdrawn.",
+ "acceptNotPending": "That invitation has already been accepted.",
+ "acceptNotFound": "That invitation is no longer there.",
+ "acceptFailed": "The invitation could not be accepted.",
+ "lastGuardian": "This is the only person looking after that profile, so this access cannot end yet. Add another guardian first, or delete the profile.",
+ "alreadyEnded": "That access has already ended.",
+ "notFound": "That access is no longer there.",
+ "revokeFailed": "The access could not be ended.",
+ "renounceFailed": "The access could not be handed back."
}
}
}
diff --git a/messages/es.json b/messages/es.json
index a1403afd0..f48a513ca 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -9585,7 +9585,8 @@
"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."
+ "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
+ "errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo."
},
"given": {
"title": "Quién puede abrir mi historial",
@@ -9742,6 +9743,21 @@
"label": "Documentos",
"detail": "Todo lo que está guardado en el archivo de documentos."
}
+ },
+ "actionError": {
+ "retry": "Inténtalo de nuevo",
+ "offline": "Esto no llegó al servidor. No ha cambiado nada: comprueba tu conexión e inténtalo de nuevo.",
+ "rateLimited": "Demasiados intentos seguidos. Espera un minuto e inténtalo de nuevo.",
+ "acceptExpired": "Esa invitación ha caducado. Pide una nueva.",
+ "acceptWithdrawn": "Esa invitación fue retirada.",
+ "acceptNotPending": "Esa invitación ya se aceptó.",
+ "acceptNotFound": "Esa invitación ya no existe.",
+ "acceptFailed": "No se pudo aceptar la invitación.",
+ "lastGuardian": "Esta es la única persona que cuida de ese perfil, así que este acceso todavía no puede terminar. Añade otra persona responsable primero, o elimina el perfil.",
+ "alreadyEnded": "Ese acceso ya ha terminado.",
+ "notFound": "Ese acceso ya no existe.",
+ "revokeFailed": "No se pudo terminar el acceso.",
+ "renounceFailed": "No se pudo devolver el acceso."
}
}
}
diff --git a/messages/fr.json b/messages/fr.json
index 3df592639..4c8d71687 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -9585,7 +9585,8 @@
"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."
+ "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
+ "errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là — vérifiez votre connexion et réessayez."
},
"given": {
"title": "Qui peut ouvrir mon dossier",
@@ -9742,6 +9743,21 @@
"label": "Documents",
"detail": "Tout ce qui est conservé dans le coffre à documents."
}
+ },
+ "actionError": {
+ "retry": "Réessayer",
+ "offline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.",
+ "rateLimited": "Trop de tentatives d'affilée. Attendez une minute et réessayez.",
+ "acceptExpired": "Cette invitation a expiré. Demandez-en une nouvelle.",
+ "acceptWithdrawn": "Cette invitation a été retirée.",
+ "acceptNotPending": "Cette invitation a déjà été acceptée.",
+ "acceptNotFound": "Cette invitation n'existe plus.",
+ "acceptFailed": "L'invitation n'a pas pu être acceptée.",
+ "lastGuardian": "Cette personne est la seule à s'occuper de ce profil, donc cet accès ne peut pas encore prendre fin. Ajoutez d'abord une autre personne responsable, ou supprimez le profil.",
+ "alreadyEnded": "Cet accès a déjà pris fin.",
+ "notFound": "Cet accès n'existe plus.",
+ "revokeFailed": "L'accès n'a pas pu être terminé.",
+ "renounceFailed": "L'accès n'a pas pu être rendu."
}
}
}
diff --git a/messages/it.json b/messages/it.json
index 9a5f4506e..28297a6e6 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -9585,7 +9585,8 @@
"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."
+ "errorStepUp": "Conferma il tuo secondo fattore e riprova.",
+ "errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova."
},
"given": {
"title": "Chi può aprire la mia cartella",
@@ -9742,6 +9743,21 @@
"label": "Documenti",
"detail": "Tutto ciò che è conservato nell'archivio documenti."
}
+ },
+ "actionError": {
+ "retry": "Riprova",
+ "offline": "La richiesta non ha raggiunto il server. Non è cambiato nulla: controlla la connessione e riprova.",
+ "rateLimited": "Troppi tentativi di fila. Aspetta un minuto e riprova.",
+ "acceptExpired": "Questo invito è scaduto. Chiedine uno nuovo.",
+ "acceptWithdrawn": "Questo invito è stato ritirato.",
+ "acceptNotPending": "Questo invito è già stato accettato.",
+ "acceptNotFound": "Questo invito non esiste più.",
+ "acceptFailed": "Non è stato possibile accettare l'invito.",
+ "lastGuardian": "Questa è l'unica persona che si occupa di quel profilo, quindi questo accesso non può ancora finire. Aggiungi prima un'altra persona responsabile, oppure elimina il profilo.",
+ "alreadyEnded": "Questo accesso è già terminato.",
+ "notFound": "Questo accesso non esiste più.",
+ "revokeFailed": "Non è stato possibile terminare l'accesso.",
+ "renounceFailed": "Non è stato possibile restituire l'accesso."
}
}
}
diff --git a/messages/pl.json b/messages/pl.json
index aa0cf4af3..0577eed2d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -9585,7 +9585,8 @@
"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."
+ "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
+ "errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest — sprawdź połączenie i spróbuj ponownie."
},
"given": {
"title": "Kto może otworzyć moją kartę",
@@ -9742,6 +9743,21 @@
"label": "Dokumenty",
"detail": "Wszystko, co jest przechowywane w sejfie dokumentów."
}
+ },
+ "actionError": {
+ "retry": "Spróbuj ponownie",
+ "offline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.",
+ "rateLimited": "Zbyt wiele prób pod rząd. Odczekaj minutę i spróbuj ponownie.",
+ "acceptExpired": "To zaproszenie wygasło. Poproś o nowe.",
+ "acceptWithdrawn": "To zaproszenie zostało wycofane.",
+ "acceptNotPending": "To zaproszenie zostało już przyjęte.",
+ "acceptNotFound": "Tego zaproszenia już nie ma.",
+ "acceptFailed": "Nie udało się przyjąć zaproszenia.",
+ "lastGuardian": "Ta osoba jako jedyna opiekuje się tym profilem, więc ten dostęp nie może się jeszcze zakończyć. Dodaj najpierw kolejną osobę opiekującą się profilem albo usuń profil.",
+ "alreadyEnded": "Ten dostęp już się zakończył.",
+ "notFound": "Tego dostępu już nie ma.",
+ "revokeFailed": "Nie udało się zakończyć dostępu.",
+ "renounceFailed": "Nie udało się zwrócić dostępu."
}
}
}
diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx
new file mode 100644
index 000000000..881a13555
--- /dev/null
+++ b/src/__tests__/sharing-recoverable-errors.test.tsx
@@ -0,0 +1,416 @@
+/**
+ * A refused sharing action says what happened, stays on screen, and can be
+ * tried again.
+ *
+ * ## The state this file exists to prevent
+ *
+ * Three of the four mutations on the shared-access panel had no failure
+ * rendering at all. `accept`, `revoke` and `renounce` fired, the request came
+ * back 409 or 410 or never left the machine, and the panel did nothing: the
+ * dialog had already closed, the list did not change, and the only signal was
+ * the absence of one. The two refusals that matter most were exactly the two
+ * that arrived silently — an invitation that had lapsed, and the last Guardian
+ * being told they may not walk away from a profile that would then have nobody
+ * looking after it. Both are recoverable, and neither was recoverable if
+ * nobody knew it had happened.
+ *
+ * ## Why the failure lives on the mutation and not in a `useState`
+ *
+ * A local `useState` set from `onError` is a second copy of a fact TanStack
+ * already holds, and the copy is the thing that goes stale: a re-render from
+ * an unrelated invalidation clears it, or a second failure overwrites the
+ * first row's message onto another row. `isError` / `error` / `variables`
+ * survive on the mutation until something explicitly resets it, and
+ * `variables` is what says WHICH row failed. That is also what makes this file
+ * possible — the panel's failure states are reachable from a static render,
+ * with no click, because they are props rather than the result of an
+ * interaction.
+ *
+ * ## What a static render can and cannot prove
+ *
+ * It proves the alert exists, is announced, is addressed to the right row,
+ * carries content contrast rather than muted meta, sits beside a retry control
+ * and leaves the row's own affordances in place. It does NOT prove the retry
+ * button re-fires the mutation with the same argument — there is no click
+ * here. That claim is the browser journey's, and the pure resolver below is
+ * where the mapping from a refusal to a sentence is actually pinned.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { ApiError } from "@/lib/api/api-fetch";
+import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants";
+
+const grantsRef: { value: GrantList } = { value: grantList() };
+
+function grantList(partial: Partial = {}): GrantList {
+ return { given: [], received: [], retentionDays: 365, ...partial };
+}
+
+/** A mutation as the panel sees one: idle unless a case says otherwise. */
+interface FakeMutation {
+ mutate: ReturnType;
+ isPending: boolean;
+ isError: boolean;
+ error: unknown;
+ variables: string | undefined;
+}
+
+function idle(): FakeMutation {
+ return {
+ mutate: vi.fn(),
+ isPending: false,
+ isError: false,
+ error: null,
+ variables: undefined,
+ };
+}
+
+function failed(error: unknown, grantId: string): FakeMutation {
+ return { ...idle(), isError: true, error, variables: grantId };
+}
+
+const acceptRef = { value: idle() };
+const revokeRef = { value: idle() };
+const renounceRef = { value: idle() };
+
+vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ useAccountGrants: () => ({
+ data: grantsRef.value,
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ }),
+ useAcceptGrant: () => acceptRef.value,
+ useRevokeGrant: () => revokeRef.value,
+ useRenounceGrant: () => renounceRef.value,
+ };
+});
+
+vi.mock("@/hooks/use-account-switch", () => ({
+ useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+import {
+ grantActionErrorKey,
+ type GrantAction,
+} from "@/components/settings/access/grant-action-error";
+import { inviteErrorKey } from "@/components/settings/access/grant-invite-card";
+import { GrantsGivenCard } from "@/components/settings/access/grants-given-card";
+import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card";
+
+function render(node: React.ReactNode): string {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+const BASE: GrantRow = {
+ id: "g1",
+ account: { id: "acct-1", username: "housemate", displayName: "Jo" },
+ access: "MANAGE",
+ scope: null,
+ state: "ACTIVE",
+ invitedAt: "2026-01-02T10:00:00.000Z",
+ acceptedAt: "2026-01-02T11:00:00.000Z",
+ expiresAt: null,
+ lastUsedAt: null,
+ revokedAt: null,
+ revokedBy: null,
+};
+
+const PENDING: GrantRow = { ...BASE, state: "PENDING", acceptedAt: null };
+
+function apiError(status: number, errorCode?: string): ApiError {
+ return new ApiError("refused", status, errorCode ? { errorCode } : undefined);
+}
+
+function reset() {
+ acceptRef.value = idle();
+ revokeRef.value = idle();
+ renounceRef.value = idle();
+ grantsRef.value = grantList();
+}
+
+describe("what a refused sharing action says", () => {
+ const cases: Array<{
+ action: GrantAction;
+ err: unknown;
+ key: string;
+ why: string;
+ }> = [
+ {
+ action: "accept",
+ err: apiError(410, "sharing.accept.expired"),
+ key: "recordSharing.actionError.acceptExpired",
+ why: "the invitation lapsed — the next step is to ask for a new one",
+ },
+ {
+ action: "accept",
+ err: apiError(410, "sharing.accept.revoked"),
+ key: "recordSharing.actionError.acceptWithdrawn",
+ why: "withdrawn is a different fact from lapsed, and has no next step",
+ },
+ {
+ action: "accept",
+ err: apiError(409, "sharing.accept.not_pending"),
+ key: "recordSharing.actionError.acceptNotPending",
+ why: "already accepted, most likely in another tab",
+ },
+ {
+ action: "accept",
+ err: apiError(404, "sharing.accept.not_found"),
+ key: "recordSharing.actionError.acceptNotFound",
+ why: "the row is gone",
+ },
+ {
+ action: "accept",
+ err: apiError(429),
+ key: "recordSharing.actionError.rateLimited",
+ why: "waiting is the recovery, so it must not read as a failure",
+ },
+ {
+ action: "revoke",
+ err: apiError(409, "managed_profile.guardian.required"),
+ key: "recordSharing.actionError.lastGuardian",
+ why: "the refusal a managed profile depends on, and it names a way out",
+ },
+ {
+ action: "renounce",
+ err: apiError(409, "managed_profile.guardian.required"),
+ key: "recordSharing.actionError.lastGuardian",
+ why: "the same refusal reached from the delegate's side",
+ },
+ {
+ action: "revoke",
+ err: apiError(409, "sharing.revoke.already_ended"),
+ key: "recordSharing.actionError.alreadyEnded",
+ why: "nothing to recover — the access is already closed",
+ },
+ {
+ action: "renounce",
+ err: apiError(409, "sharing.renounce.already_ended"),
+ key: "recordSharing.actionError.alreadyEnded",
+ why: "same, from the other side",
+ },
+ {
+ action: "revoke",
+ err: apiError(404, "sharing.revoke.not_found"),
+ key: "recordSharing.actionError.notFound",
+ why: "the row is gone",
+ },
+ {
+ action: "accept",
+ err: new TypeError("Failed to fetch"),
+ key: "recordSharing.actionError.offline",
+ why: "never reached the server — retrying is the whole recovery",
+ },
+ {
+ action: "revoke",
+ err: new TypeError("Failed to fetch"),
+ key: "recordSharing.actionError.offline",
+ why: "same, and the row is unchanged because nothing was sent",
+ },
+ {
+ action: "renounce",
+ err: new TypeError("Failed to fetch"),
+ key: "recordSharing.actionError.offline",
+ why: "same",
+ },
+ {
+ action: "accept",
+ err: apiError(500),
+ key: "recordSharing.actionError.acceptFailed",
+ why: "an unclassified failure still says which act failed",
+ },
+ {
+ action: "revoke",
+ err: apiError(500),
+ key: "recordSharing.actionError.revokeFailed",
+ why: "same",
+ },
+ {
+ action: "renounce",
+ err: apiError(500),
+ key: "recordSharing.actionError.renounceFailed",
+ why: "same",
+ },
+ ];
+
+ for (const { action, err, key, why } of cases) {
+ it(`${action}: ${why}`, () => {
+ expect(grantActionErrorKey(err, action)).toBe(key);
+ });
+ }
+
+ it("tells the refusals apart rather than collapsing them", () => {
+ // The failure this table is written against is one message for
+ // everything. Distinct codes must produce distinct sentences, and the
+ // count is asserted so a resolver that returned one key for all of them
+ // fails here rather than passing every row above by accident.
+ const distinct = new Set(
+ cases.map(({ err, action }) => grantActionErrorKey(err, action)),
+ );
+ expect(distinct.size).toBeGreaterThan(8);
+ });
+});
+
+describe("a refused invitation keeps what was typed", () => {
+ const INVITE_CARD = readFileSync(
+ join(process.cwd(), "src/components/settings/access/grant-invite-card.tsx"),
+ "utf8",
+ );
+
+ it("separates a request that never arrived from one the server refused", () => {
+ // "The invitation could not be sent" invites somebody to change the form,
+ // and after a dropped connection the form is fine.
+ expect(inviteErrorKey(new TypeError("Failed to fetch"))).toBe(
+ "recordSharing.invite.errorOffline",
+ );
+ expect(inviteErrorKey(apiError(404))).toBe(
+ "recordSharing.invite.errorNoAccount",
+ );
+ expect(inviteErrorKey(apiError(409, "sharing.invite.duplicate"))).toBe(
+ "recordSharing.invite.errorDuplicate",
+ );
+ });
+
+ it("clears the form only where the invitation actually went", () => {
+ // A static render cannot press the button, so the claim is made about the
+ // code path instead: every field reset lives inside `onSuccess`. A reset
+ // that migrated into `onError` — or out of the callbacks entirely — would
+ // wipe a typed identifier and eight ticked sections on a failure the
+ // person is being asked to retry.
+ const start = INVITE_CARD.indexOf("onSuccess:");
+ const split = INVITE_CARD.indexOf("onError:", start);
+ // The options object ends at the first ` },` after the error arm, so
+ // the slice stops at the mutation rather than running into the JSX below,
+ // where a `setNarrowed(false)` on a radio would satisfy the matcher for
+ // the wrong reason.
+ const end = INVITE_CARD.indexOf("\n },", split);
+ const onSuccess = INVITE_CARD.slice(start, split);
+ const onError = INVITE_CARD.slice(split, end);
+
+ // Positive controls: both slices really contain the callback they name.
+ expect(onSuccess).toContain("setInvited");
+ expect(onError).toContain("inviteErrorKey");
+
+ for (const reset of [
+ 'setIdentifier("")',
+ 'setExpiresOn("")',
+ 'setAccess("READ")',
+ "setNarrowed(false)",
+ "setSections([])",
+ ]) {
+ expect(onSuccess, reset).toContain(reset);
+ expect(onError, reset).not.toContain(reset);
+ }
+ });
+});
+
+describe("the refusal stays on screen, addressed to its own row", () => {
+ it("announces a refused acceptance and leaves the invitation acceptable", () => {
+ reset();
+ grantsRef.value = grantList({ received: [PENDING] });
+ acceptRef.value = failed(apiError(410, "sharing.accept.expired"), "g1");
+
+ const html = render();
+
+ // Announced, not merely printed.
+ expect(html).toContain('data-slot="grant-action-error"');
+ expect(html).toContain('role="alert"');
+ expect(html).toContain("That invitation has lapsed");
+ // Addressed to the row it belongs to, so a second invitation below it
+ // does not wear somebody else's failure.
+ expect(html).toContain('data-grant-error-for="g1"');
+ // The state survives: the row is still there and still offers the act.
+ expect(html).toContain('data-grant-id="g1"');
+ expect(html).toContain('data-slot="grant-accept"');
+ // And there is a way to try again.
+ expect(html).toContain('data-slot="grant-action-retry"');
+ });
+
+ it("does not paint the failure onto a row that did not fail", () => {
+ reset();
+ grantsRef.value = grantList({
+ received: [PENDING, { ...PENDING, id: "g2" }],
+ });
+ acceptRef.value = failed(apiError(410, "sharing.accept.expired"), "g1");
+
+ const html = render();
+
+ expect(html.match(/data-slot="grant-action-error"/g)).toHaveLength(1);
+ expect(html).toContain('data-grant-error-for="g1"');
+ expect(html).not.toContain('data-grant-error-for="g2"');
+ });
+
+ it("names the last-Guardian refusal and the way out of it", () => {
+ reset();
+ grantsRef.value = grantList({ given: [BASE] });
+ revokeRef.value = failed(
+ apiError(409, "managed_profile.guardian.required"),
+ "g1",
+ );
+
+ const html = render();
+
+ expect(html).toContain('data-slot="grant-action-error"');
+ // The sentence has to carry the recovery. "Could not end access" is true
+ // and useless; the owner needs to know a second guardian is the fix.
+ expect(html).toContain("Add another guardian first");
+ // The row keeps its control, so the owner can act on the advice and
+ // return to the same button.
+ expect(html).toContain('data-slot="grant-revoke"');
+ });
+
+ it("reaches the same refusal from the delegate's side", () => {
+ reset();
+ grantsRef.value = grantList({ received: [BASE] });
+ renounceRef.value = failed(
+ apiError(409, "managed_profile.guardian.required"),
+ "g1",
+ );
+
+ const html = render();
+
+ expect(html).toContain('data-slot="grant-action-error"');
+ expect(html).toContain("Add another guardian first");
+ expect(html).toContain('data-slot="grant-renounce"');
+ });
+
+ it("keeps material copy in content contrast, not muted meta", () => {
+ reset();
+ grantsRef.value = grantList({ received: [PENDING] });
+ acceptRef.value = failed(new TypeError("Failed to fetch"), "g1");
+
+ const html = render();
+ const alert = /<[^>]*data-slot="grant-action-error"[^>]*>/.exec(html)?.[0];
+
+ expect(alert).toBeDefined();
+ // A refusal somebody has to act on is content. UI-STANDARDS §3 reserves
+ // muted for meta, and a sentence nobody reads is a sentence nobody acts
+ // on.
+ expect(alert).toContain("text-destructive");
+ expect(alert).not.toContain("text-muted-foreground");
+ });
+
+ it("shows nothing at all while every mutation is idle", () => {
+ reset();
+ grantsRef.value = grantList({ given: [BASE], received: [PENDING] });
+
+ expect(render()).not.toContain(
+ 'data-slot="grant-action-error"',
+ );
+ expect(render()).not.toContain(
+ 'data-slot="grant-action-error"',
+ );
+ });
+});
diff --git a/src/components/settings/access/grant-action-error.tsx b/src/components/settings/access/grant-action-error.tsx
new file mode 100644
index 000000000..62e95682f
--- /dev/null
+++ b/src/components/settings/access/grant-action-error.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { ApiError } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.37.0 — what a refused accept, revoke or renounce says, and where it says
+ * it.
+ *
+ * ## The state this replaces
+ *
+ * These three mutations had no failure rendering at all. They fired, the
+ * server answered 409 or 410, or the request never left the machine, and the
+ * panel did nothing — the confirm dialog had already closed, the list did not
+ * change, and the only signal was the absence of one. The two refusals that
+ * matter most were the two that arrived most quietly: an invitation that had
+ * lapsed, and the last Guardian being refused a renunciation that would leave
+ * a managed profile with nobody looking after it. Both are recoverable, and
+ * neither is recoverable by somebody who was not told.
+ *
+ * ## Every arm names its own recovery
+ *
+ * A refusal is only worth rendering if it changes what the person does next,
+ * so the sentences are split by what the next step is rather than by status
+ * code. "Lapsed" leads to asking for a new invitation; "withdrawn" leads
+ * nowhere and says so; the last-Guardian refusal names the two ways out the
+ * lifecycle actually offers. The offline arm is the one where retrying IS the
+ * recovery, so it is separated from a server refusal even though both surface
+ * as a failed mutation: a request that never left the machine changed nothing,
+ * and telling somebody their access "could not be ended" when it was never
+ * asked is a different and worse sentence.
+ *
+ * `ApiError` carries the server's stable `meta.errorCode`; anything that is
+ * not an `ApiError` never reached a response at all, which is what makes the
+ * offline test a type check rather than a status check.
+ */
+export type GrantAction = "accept" | "revoke" | "renounce";
+
+/** The generic sentence for each act, when nothing more specific applies. */
+const FAILED_KEY: Record = {
+ accept: "recordSharing.actionError.acceptFailed",
+ revoke: "recordSharing.actionError.revokeFailed",
+ renounce: "recordSharing.actionError.renounceFailed",
+};
+
+/**
+ * The message key for a refused grant action.
+ *
+ * Pure and exported so the mapping can be pinned without a click: the panel's
+ * error states are otherwise only reachable by driving a browser, and a
+ * mapping nobody can test is a mapping that drifts.
+ */
+export function grantActionErrorKey(err: unknown, action: GrantAction): string {
+ // Not an `ApiError` means no response came back — a dropped connection, a
+ // sleeping laptop, a server that is not there. Nothing was decided, so the
+ // sentence says so and points at the retry rather than reporting a refusal
+ // the server never issued.
+ if (!(err instanceof ApiError)) return "recordSharing.actionError.offline";
+
+ const code =
+ typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null;
+
+ switch (code) {
+ // The one refusal that protects a person rather than a row. It reaches
+ // both sides — the owner withdrawing the last Guardian's access and the
+ // last Guardian handing it back — and it is the same fact either way, so
+ // it is the same sentence.
+ case "managed_profile.guardian.required":
+ return "recordSharing.actionError.lastGuardian";
+ case "sharing.accept.expired":
+ return "recordSharing.actionError.acceptExpired";
+ case "sharing.accept.revoked":
+ return "recordSharing.actionError.acceptWithdrawn";
+ case "sharing.accept.not_pending":
+ return "recordSharing.actionError.acceptNotPending";
+ case "sharing.accept.not_found":
+ return "recordSharing.actionError.acceptNotFound";
+ case "sharing.revoke.already_ended":
+ case "sharing.renounce.already_ended":
+ return "recordSharing.actionError.alreadyEnded";
+ case "sharing.revoke.not_found":
+ case "sharing.renounce.not_found":
+ return "recordSharing.actionError.notFound";
+ default:
+ break;
+ }
+
+ // Waiting is the recovery, so it must not read as a failure. The accept
+ // route rate-limits without an error code, hence the status.
+ if (err.status === 429) return "recordSharing.actionError.rateLimited";
+
+ return FAILED_KEY[action];
+}
+
+/**
+ * The refusal, on the row it belongs to.
+ *
+ * `role="alert"` because it appears after the act rather than with the page:
+ * a person who pressed a button and got nothing has already looked away, and
+ * a screen reader that does not announce it is the same silence the panel had
+ * before. `text-destructive` because this is content somebody has to act on —
+ * UI-STANDARDS §3 reserves muted for meta, and a refusal rendered as meta is a
+ * refusal nobody reads.
+ *
+ * `data-grant-error-for` carries the row id so a panel holding two failed rows
+ * cannot show one row's message against another's, and so a browser journey
+ * can assert which row was refused rather than that something somewhere went
+ * red.
+ */
+export function GrantActionAlert({
+ grantId,
+ message,
+ onRetry,
+ retrying = false,
+}: {
+ grantId: string;
+ message: string;
+ onRetry: () => void;
+ retrying?: boolean;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ {message}
+
+
+ );
+}
diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx
index be3a92285..9c34a61b3 100644
--- a/src/components/settings/access/grant-invite-card.tsx
+++ b/src/components/settings/access/grant-invite-card.tsx
@@ -654,10 +654,15 @@ function useInvitationOwnerTimezone(): string {
* field" and "you already have your own record".
*/
export function inviteErrorKey(err: unknown): string {
+ // Nothing that is not an `ApiError` ever reached a response, so no
+ // invitation was refused — the request did not arrive. Said as its own case
+ // because "the invitation could not be sent" invites somebody to change the
+ // form, and the form is fine. The typed identifier, the level and the ticked
+ // sections all survive a failed mutation, so the retry is the same button.
+ if (!(err instanceof ApiError)) return "recordSharing.invite.errorOffline";
+
const code =
- err instanceof ApiError && typeof err.meta?.errorCode === "string"
- ? err.meta.errorCode
- : null;
+ typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null;
switch (code) {
case "sharing.invite.self":
return "recordSharing.invite.errorSelf";
diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx
index f32e0c5d1..f90b87db0 100644
--- a/src/components/settings/access/grants-given-card.tsx
+++ b/src/components/settings/access/grants-given-card.tsx
@@ -2,6 +2,10 @@
import { Loader2, Users } from "lucide-react";
+import {
+ GrantActionAlert,
+ grantActionErrorKey,
+} from "@/components/settings/access/grant-action-error";
import { GrantRowItem } from "@/components/settings/access/grant-row";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -75,6 +79,7 @@ export function GrantsGivenCard() {
key={grant.id}
grant={grant}
side="given"
+ notice={renderFailure(grant)}
actions={renderRevoke(grant)}
/>
))}
@@ -84,6 +89,28 @@ export function GrantsGivenCard() {
);
+ /**
+ * A refused revoke, on the row it was refused for.
+ *
+ * The confirm dialog has already closed by the time the server answers, so
+ * without this a refusal is invisible: the list looks the same, and the one
+ * refusal that matters — a managed profile that would be left with nobody
+ * looking after it — arrived as silence. Read off the mutation rather than
+ * copied into local state, so `variables` is what decides which row wears
+ * it.
+ */
+ function renderFailure(grant: GrantRow) {
+ if (!revoke.isError || revoke.variables !== grant.id) return null;
+ return (
+ revoke.mutate(grant.id)}
+ />
+ );
+ }
+
function renderRevoke(grant: GrantRow) {
// Only a live grant can be ended. An already-ended row keeps its history
// and loses its button, rather than offering an act that would 409.
diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx
index 4ed9eb633..dd7f581b5 100644
--- a/src/components/settings/access/grants-received-card.tsx
+++ b/src/components/settings/access/grants-received-card.tsx
@@ -2,6 +2,11 @@
import { FolderOpen, Loader2 } from "lucide-react";
+import {
+ GrantActionAlert,
+ grantActionErrorKey,
+ type GrantAction,
+} from "@/components/settings/access/grant-action-error";
import { GrantRowItem } from "@/components/settings/access/grant-row";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -34,6 +39,18 @@ import {
* at the row and should not have to go hunting for the switcher.
* * **Hand back.** The delegate's own way out, attributed as theirs so the
* owner's record can tell "I withdrew it" from "he gave it back".
+ *
+ * v1.37.0 — both acts can now be refused in ways the person can do something
+ * about, and both used to fail in silence. An invitation that lapsed before it
+ * was opened, and the last Guardian being told they may not hand back a
+ * profile that would then have nobody looking after it. The refusal renders on
+ * its own row, announced, with the row's control still there.
+ *
+ * The failed state is read off the mutation rather than copied into a
+ * `useState` from `onError`. TanStack keeps `isError` / `error` / `variables`
+ * until something resets it, and `variables` is what says which row failed — a
+ * local copy would be a second version of that fact, and it is the copy that
+ * goes stale when an unrelated invalidation re-renders the panel.
*/
export function GrantsReceivedCard() {
const { t } = useTranslations();
@@ -78,7 +95,12 @@ export function GrantsReceivedCard() {
key={grant.id}
grant={grant}
side="received"
- notice={renderConsent(grant)}
+ notice={
+ <>
+ {renderConsent(grant)}
+ {renderFailure(grant)}
+ >
+ }
actions={renderActions(grant)}
/>
))}
@@ -88,6 +110,52 @@ export function GrantsReceivedCard() {
);
+ /**
+ * The refusal this row is carrying, if it is the row that was refused.
+ *
+ * Guarded on `variables` rather than rendered under the list, because a
+ * panel with two pending invitations would otherwise show one row's failure
+ * with no way to tell which act it belonged to.
+ */
+ function renderFailure(grant: GrantRow) {
+ const failure = failedAction(grant.id);
+ if (!failure) return null;
+ return (
+
+ );
+ }
+
+ /** Which of the two acts failed on this row, and how to run it again. */
+ function failedAction(grantId: string): {
+ action: GrantAction;
+ error: unknown;
+ pending: boolean;
+ retry: () => void;
+ } | null {
+ if (accept.isError && accept.variables === grantId) {
+ return {
+ action: "accept",
+ error: accept.error,
+ pending: accept.isPending,
+ retry: () => accept.mutate(grantId),
+ };
+ }
+ if (renounce.isError && renounce.variables === grantId) {
+ return {
+ action: "renounce",
+ error: renounce.error,
+ pending: renounce.isPending,
+ retry: () => renounce.mutate(grantId),
+ };
+ }
+ return null;
+ }
+
/**
* The delegate's half of the consent, on the screen where they give it.
*
From 0cdfb97a451fd14047b83d1e560600e6200a0921 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 23:46:43 +0200
Subject: [PATCH 3/9] test: cover sharing accessibility states
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shared-record accessibility suite had four tests for five states: success
and empty shared one, and neither of them ever entered an empty state. A
browser run reports what it ran and never what it did not, so a state nobody
wrote a test for produced exactly the same output as a state that passed.
The five states are enumerated in a fixture, each test names its state, and a
fast guard reads the spec back against the list — a state that loses its test,
its anchor, or one of its proofs now fails in the ordinary gate rather than at
the point somebody notices the spinner is silent.
Every state runs four proofs: an axe scan, the document reading order, either
a keyboard reach with a visible focus indicator or a live-region announcement
for the one state with nothing to press, and, where the copy is something a
person has to act on, that it renders as content rather than as muted meta and
clears the contrast floor. The retry control on the read-failure card gained a
stable slot so the keyboard proof targets the affordance rather than its
wording.
The eight release journeys are enumerated in the same fixture.
---
...v137-sharing-managed-profiles-a11y.spec.ts | 280 +++++++++++++++++-
.../sharing-accessibility-states.test.tsx | 230 ++++++++++++++
src/components/ui/query-error-card.tsx | 11 +-
tests/fixtures/v137/e2e-journeys.ts | 176 +++++++++++
4 files changed, 686 insertions(+), 11 deletions(-)
create mode 100644 src/__tests__/sharing-accessibility-states.test.tsx
create mode 100644 tests/fixtures/v137/e2e-journeys.ts
diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
index 6396eba29..87c3e3128 100644
--- a/e2e/v137-sharing-managed-profiles-a11y.spec.ts
+++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
@@ -4,13 +4,190 @@ import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { SCOPE_A11Y_STORAGE_STATE_PATH } from "./setup/test-helpers";
-async function scan(page: Page) {
+/**
+ * The five states of a shared record, each proved usable rather than merely
+ * reachable.
+ *
+ * Accessibility work concentrates on the state where everything worked,
+ * because that is the state the person building the screen is looking at. The
+ * other four are where somebody who cannot see the screen is most stranded: a
+ * spinner nobody announces, an error card that reads as an empty page, a
+ * refusal whose only way out is a control no keyboard reaches. This file had
+ * four tests for five states, success and empty shared one, and neither of
+ * them entered an empty state at all.
+ *
+ * Each test now names its state in brackets, enters it, and runs four proofs:
+ * an axe scan, the reading order, either a keyboard reach or an announcement,
+ * and — where the state has copy somebody has to act on — that the copy is
+ * content rather than muted meta.
+ *
+ * `src/__tests__/sharing-accessibility-states.test.tsx` reads this file back
+ * against `tests/fixtures/v137/e2e-journeys.ts` and fails if a state loses its
+ * test or one of its proofs. A browser suite reports what it ran, never what
+ * it did not, so the enumeration lives where a fast test can check it.
+ */
+
+/** WCAG 2.1 AA, as the rest of the suite scans it. */
+async function scanForViolations(page: Page) {
const result = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(result.violations).toEqual([]);
}
+/**
+ * The named elements appear in this order in the document.
+ *
+ * Reading order is what a screen reader follows and what a keyboard walks, and
+ * CSS can put a control visually above an explanation while the DOM keeps it
+ * below. Every selector must resolve: an absent element would otherwise make
+ * an order of one trivially correct, which is the shape of check that passes
+ * for the whole life of a regression.
+ */
+async function expectReaderOrder(page: Page, selectors: string[]) {
+ expect(selectors.length).toBeGreaterThan(1);
+ const positions = await page.evaluate((list) => {
+ const all = Array.from(document.querySelectorAll("*"));
+ return list.map((selector) => {
+ const element = document.querySelector(selector);
+ return element ? all.indexOf(element) : -1;
+ });
+ }, selectors);
+
+ const missing = selectors.filter((_, i) => positions[i] === -1);
+ expect(missing, `not on the page: ${missing.join(", ")}`).toEqual([]);
+ expect(positions, `reading order is ${selectors.join(" then ")}`).toEqual(
+ [...positions].sort((a, b) => a - b),
+ );
+}
+
+/**
+ * Tab from the top of the document until focus lands on the target.
+ *
+ * Real Tab presses rather than `element.focus()`, for two reasons: a control
+ * that is present but not in the tab order fails here, which is the defect
+ * this proves the absence of; and `:focus-visible` only applies to keyboard
+ * focus, so the ring can be asserted at the same time. The invitation form's
+ * own radios shipped `sr-only` inputs whose focus ring landed on a zero-size
+ * box — the whole class of defect is invisible to `element.focus()`.
+ */
+async function expectKeyboardReach(page: Page, selector: string) {
+ await expect(page.locator(selector).first()).toBeVisible();
+ await page.evaluate(() => {
+ (document.activeElement as HTMLElement | null)?.blur();
+ document.body.focus();
+ });
+
+ const MAX_TABS = 120;
+ let reached = false;
+ for (let i = 0; i < MAX_TABS && !reached; i += 1) {
+ await page.keyboard.press("Tab");
+ reached = await page.evaluate(
+ (sel) => document.activeElement?.matches(sel) ?? false,
+ selector,
+ );
+ }
+ expect(reached, `${selector} is not reachable by Tab`).toBe(true);
+
+ // And the person can see where they are. A focusable control with no
+ // visible indicator is WCAG 2.4.7 and is exactly what an `sr-only` input
+ // produces.
+ const outlined = await page.evaluate((sel) => {
+ const element = document.querySelector(sel);
+ if (!element) return false;
+ if (element.matches(":focus-visible")) return true;
+ // Some rings are painted by an ancestor via `has-[:focus-visible]`.
+ return Boolean(element.closest(":has(:focus-visible)"));
+ }, selector);
+ expect(outlined, `${selector} takes focus without showing it`).toBe(true);
+}
+
+/**
+ * The state says out loud that it is happening.
+ *
+ * For the one state with nothing to press. A spinner is a picture; without a
+ * live region and a label it is a blank page to a screen reader, and the hold
+ * before the active record resolves is precisely when somebody must not be
+ * told nothing.
+ */
+async function expectAnnounced(page: Page, selector: string) {
+ const region = page.locator(selector).first();
+ await expect(region).toBeVisible();
+ await expect(region).toHaveAttribute("role", /status|alert/);
+ const label = await region.evaluate((node) =>
+ (node.textContent ?? "").trim(),
+ );
+ expect(label.length, `${selector} announces nothing`).toBeGreaterThan(0);
+}
+
+/**
+ * Copy somebody has to act on is content, not meta.
+ *
+ * Two halves, and both are needed. The class check is the design standard —
+ * UI-STANDARDS §3 reserves `text-muted-foreground` for meta, and consent or
+ * safety text rendered as meta is text people skip. The ratio check is the
+ * measurable one: it walks up for an opaque background and asserts the AA
+ * floor for body text, so a future token change that keeps the class and
+ * loses the contrast still fails.
+ */
+async function expectContentContrast(page: Page, selector: string) {
+ const result = await page.evaluate((sel) => {
+ const element = document.querySelector(sel);
+ if (!element) return null;
+
+ const parse = (value: string): [number, number, number, number] | null => {
+ const nums = value.match(/[\d.]+/g);
+ if (!nums || nums.length < 3) return null;
+ return [
+ Number(nums[0]),
+ Number(nums[1]),
+ Number(nums[2]),
+ nums.length > 3 ? Number(nums[3]) : 1,
+ ];
+ };
+
+ const luminance = ([r, g, b]: [number, number, number, number]): number => {
+ const channel = (c: number) => {
+ const v = c / 255;
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+ };
+
+ const foreground = parse(getComputedStyle(element).color);
+ let node: Element | null = element;
+ let background: [number, number, number, number] | null = null;
+ while (node && !background) {
+ const parsed = parse(getComputedStyle(node).backgroundColor);
+ if (parsed && parsed[3] > 0) background = parsed;
+ node = node.parentElement;
+ }
+ if (!foreground || !background) return null;
+
+ const light = Math.max(luminance(foreground), luminance(background));
+ const dark = Math.min(luminance(foreground), luminance(background));
+
+ // The class list of the element and of any descendant carrying the text:
+ // the copy is sometimes a wrapper's child.
+ const classes = [element, ...Array.from(element.querySelectorAll("*"))]
+ .map((n) => n.className)
+ .filter((c): c is string => typeof c === "string")
+ .join(" ");
+
+ return { ratio: (light + 0.05) / (dark + 0.05), classes };
+ }, selector);
+
+ expect(result, `${selector} is not on the page`).not.toBeNull();
+ expect(
+ result!.classes,
+ `${selector} renders material copy in the muted token`,
+ ).not.toContain("text-muted-foreground");
+ expect(
+ result!.ratio,
+ `${selector} is below the AA floor for body text`,
+ ).toBeGreaterThanOrEqual(4.5);
+}
+
async function openProfileRecord(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "User menu" }).first().click();
@@ -34,30 +211,81 @@ test.describe("shared record accessibility states", () => {
test.describe.configure({ mode: "serial" });
test.use({ storageState: SCOPE_A11Y_STORAGE_STATE_PATH });
- test("success and empty shared profile states have no WCAG violations", async ({
+ test("[success] the shared profile is scannable, ordered and escapable", async ({
page,
}) => {
await openProfileRecord(page);
await expect(page.locator('[data-slot="profile-summary"]')).toBeVisible();
- await scan(page);
+
+ await scanForViolations(page);
+ // Whose record this is comes before what is in it. A reader who meets the
+ // record before the banner has already read somebody else's health data
+ // believing it is their own.
+ await expectReaderOrder(page, [
+ '[data-slot="shared-record-banner"]',
+ '[data-slot="shared-record-banner-context"]',
+ '[data-slot="profile-summary"]',
+ ]);
+ await expectContentContrast(
+ page,
+ '[data-slot="shared-record-banner-context"]',
+ );
+ await expectKeyboardReach(page, '[data-slot="shared-record-banner-exit"]');
+
await leaveRecord(page);
});
- test("the capability hydration loading state has no WCAG violations", async ({
+ test("[empty] nothing shared yet reads as an answer, not a failure", async ({
+ page,
+ }) => {
+ // The delegate's own shared-access panel. They hold grants and have given
+ // none, so the "who can open my record" card is genuinely empty — which is
+ // a state the suite claimed to cover and never entered.
+ await page.goto("/settings/access");
+ await expect(
+ page.locator('[data-slot="grants-given-card"] [data-slot="empty-state"]'),
+ ).toBeVisible();
+
+ await scanForViolations(page);
+ await expectReaderOrder(page, [
+ '[data-slot="grant-invite-card"]',
+ '[data-slot="grants-given-card"]',
+ '[data-slot="grants-given-card"] [data-slot="empty-state"]',
+ ]);
+ await expectContentContrast(
+ page,
+ '[data-slot="grants-given-card"] [data-slot="empty-state"] p',
+ );
+ // The way out of an empty state is the act that fills it.
+ await expectKeyboardReach(page, '[data-slot="grant-invite-identifier"]');
+ });
+
+ test("[loading] the hold before the active record is known is announced", async ({
page,
}) => {
await page.route("**/api/auth/me", async (route) => {
- await new Promise((resolve) => setTimeout(resolve, 250));
+ await new Promise((resolve) => setTimeout(resolve, 1500));
await route.continue();
});
await page.goto("/measurements");
await expect(
page.locator('[data-slot="record-scope-hydration-gate"]'),
).toBeVisible();
- await scan(page);
+
+ await scanForViolations(page);
+ await expectAnnounced(page, '[data-slot="record-scope-hydration-gate"]');
+ // The label lives inside the region, so a reader that reaches the region
+ // reaches the sentence. A label rendered as a sibling would be announced
+ // separately or not at all.
+ await expectReaderOrder(page, [
+ '[data-slot="record-scope-hydration-gate"]',
+ '[data-slot="record-scope-hydration-gate"] .sr-only',
+ ]);
+
+ await page.unroute("**/api/auth/me");
});
- test("the shared profile read error state has no WCAG violations", async ({
+ test("[error] a failed read says so and hands the retry to a keyboard", async ({
page,
}) => {
await page.route("**/api/profile/summary**", (route) =>
@@ -65,11 +293,23 @@ test.describe("shared record accessibility states", () => {
);
await openProfileRecord(page);
await expect(page.locator('[data-slot="query-error-card"]')).toBeVisible();
- await scan(page);
+
+ await scanForViolations(page);
+ await expectReaderOrder(page, [
+ '[data-slot="shared-record-banner"]',
+ '[data-slot="query-error-card"]',
+ '[data-slot="query-error-retry"]',
+ ]);
+ // The sentence that says the read failed. Muted here would put "this did
+ // not load" in the same register as a timestamp.
+ await expectContentContrast(page, '[data-slot="query-error-card"] p');
+ await expectKeyboardReach(page, '[data-slot="query-error-retry"]');
+
+ await page.unroute("**/api/profile/summary**");
await leaveRecord(page);
});
- test("the shared-record refusal state has no WCAG violations", async ({
+ test("[refusal] a surface sharing does not cover keeps a way back", async ({
page,
}) => {
await openProfileRecord(page);
@@ -77,7 +317,27 @@ test.describe("shared record accessibility states", () => {
await expect(
page.locator('[data-slot="shared-record-unavailable"]'),
).toBeVisible();
- await scan(page);
+
+ await scanForViolations(page);
+ // The explanation before the button. A reader who meets "leave this
+ // record" first is being offered an act before being told why.
+ await expectReaderOrder(page, [
+ '[data-slot="shared-record-unavailable"]',
+ '[data-slot="shared-record-unavailable"] p',
+ '[data-slot="shared-record-unavailable-leave"]',
+ ]);
+ await expectContentContrast(
+ page,
+ '[data-slot="shared-record-unavailable"] p',
+ );
+ await expectKeyboardReach(
+ page,
+ '[data-slot="shared-record-unavailable-leave"]',
+ );
+
await page.locator('[data-slot="shared-record-unavailable-leave"]').click();
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toHaveCount(0);
});
});
diff --git a/src/__tests__/sharing-accessibility-states.test.tsx b/src/__tests__/sharing-accessibility-states.test.tsx
new file mode 100644
index 000000000..907bd1a0a
--- /dev/null
+++ b/src/__tests__/sharing-accessibility-states.test.tsx
@@ -0,0 +1,230 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ ACCESSIBILITY_STATES,
+ SHARING_ACCESSIBILITY_STATES,
+ V137_E2E_JOURNEYS,
+} from "../../tests/fixtures/v137/e2e-journeys";
+
+/**
+ * All five states are claimed by a browser test that really examines them.
+ *
+ * ## Why a fast test guards a browser suite
+ *
+ * A Playwright run reports what it ran, never what it did not. "The
+ * accessibility suite is green" and "the accessibility suite covers the error
+ * state" are different sentences, and only the first one is ever checked —
+ * a state nobody wrote a test for produces exactly the same output as a state
+ * that passes. Before this file the suite had four tests for five states, with
+ * success and empty sharing one and neither of them actually entering an empty
+ * one.
+ *
+ * So the states are enumerated in `tests/fixtures/v137/e2e-journeys.ts` and
+ * this file reads the spec back against the list. It cannot tell whether the
+ * assertions inside a test are any good. It can tell that each state has a
+ * test, that the test enters the state (there is an anchor, and the anchor is
+ * a slot the component tree really renders), and that the test performs each
+ * proof the state's own record says it needs — scan, reader order, and either
+ * a keyboard reach or an announcement.
+ *
+ * ## The trap this file is written around
+ *
+ * Every assertion here is over a MATCHED set, and an empty match set satisfies
+ * most of them silently: a spec path that no longer exists reads as a spec
+ * with no offending tests. Each leg therefore pins the size of what it
+ * matched — five states, five titles, eight journeys, a non-zero count of
+ * helper calls — and the parse is proved against a synthetic spec at the
+ * bottom, so the day the extractor stops seeing `test("…")` is the day this
+ * file fails rather than the day it goes quiet.
+ */
+const ROOT = process.cwd();
+const A11Y_SPEC = join(ROOT, "e2e/v137-sharing-managed-profiles-a11y.spec.ts");
+
+/** The helper each state's test has to call, by what the state offers. */
+const SCAN = "scanForViolations";
+const READER_ORDER = "expectReaderOrder";
+const KEYBOARD = "expectKeyboardReach";
+const ANNOUNCED = "expectAnnounced";
+const CONTRAST = "expectContentContrast";
+
+/**
+ * Split a spec into `title -> body`.
+ *
+ * Bodies are delimited by the next `test(` rather than by brace matching: a
+ * body containing a string with a brace in it is common, a body containing the
+ * literal `test(` is not. Exported and exercised against a synthetic spec
+ * below, because a splitter that returned an empty map would make every leg
+ * here pass by matching nothing.
+ */
+export function testBodies(source: string): Map {
+ const bodies = new Map();
+ // `test.only(` and `test.skip(` are tests; `test.describe(` is not, and the
+ // synthetic spec below is what caught the first version treating it as one.
+ const opener = /\btest(?:\.(?!describe)\w+)*\(\s*"([^"]+)"/g;
+ const starts: Array<{ title: string; from: number }> = [];
+ let match: RegExpExecArray | null;
+ while ((match = opener.exec(source)) !== null) {
+ starts.push({ title: match[1], from: match.index });
+ }
+ for (let i = 0; i < starts.length; i += 1) {
+ const to = i + 1 < starts.length ? starts[i + 1].from : source.length;
+ bodies.set(starts[i].title, source.slice(starts[i].from, to));
+ }
+ return bodies;
+}
+
+describe("the state parser sees what it looks for", () => {
+ const SYNTHETIC = `
+ import { expect, test } from "./setup/test";
+
+ test.describe("a group", () => {
+ test("first — success", async ({ page }) => {
+ await scanForViolations(page);
+ });
+
+ test("second — refusal", async ({ page }) => {
+ await expectKeyboardReach(page, '[data-slot="x"]');
+ });
+ });
+ `;
+
+ it("splits a spec into its test bodies", () => {
+ const bodies = testBodies(SYNTHETIC);
+ expect([...bodies.keys()]).toEqual(["first — success", "second — refusal"]);
+ expect(bodies.get("first — success")).toContain(SCAN);
+ // And no body bleeds into the next one, which is what would make a single
+ // well-instrumented test satisfy the checks for every state.
+ expect(bodies.get("first — success")).not.toContain(KEYBOARD);
+ });
+
+ it("does not treat a describe block as a test", () => {
+ expect(testBodies(SYNTHETIC).has("a group")).toBe(false);
+ });
+});
+
+describe("the five states are enumerated once each", () => {
+ it("lists exactly the five, with no state named twice", () => {
+ expect(ACCESSIBILITY_STATES).toHaveLength(5);
+ expect(SHARING_ACCESSIBILITY_STATES).toHaveLength(5);
+ expect(SHARING_ACCESSIBILITY_STATES.map((c) => c.state).sort()).toEqual(
+ [...ACCESSIBILITY_STATES].sort(),
+ );
+ });
+
+ it("anchors every state on a slot the tree really renders", () => {
+ // A state can otherwise be added to the list with an anchor nothing emits,
+ // and the browser test would then wait for an element that cannot appear —
+ // which fails, but fails as a timeout that reads like flake rather than
+ // like a missing surface.
+ for (const c of SHARING_ACCESSIBILITY_STATES) {
+ const source = readFileSync(join(ROOT, c.rendersIn), "utf8");
+ // The last `[data-slot="…"]` in a compound selector is the one the
+ // module has to emit; the prefix is context from an ancestor.
+ const slots = [...c.anchor.matchAll(/data-slot="([^"]+)"/g)].map(
+ (m) => m[1],
+ );
+ expect(slots.length, c.anchor).toBeGreaterThan(0);
+ expect(source, `${c.rendersIn} does not emit ${c.anchor}`).toContain(
+ `data-slot="${slots[slots.length - 1]}"`,
+ );
+ }
+ });
+
+ it("gives every state a way forward or says it has none", () => {
+ // Exactly one state may be without a control, and it is the one nobody can
+ // act in. A second `null` here would mean a dead end shipped as a state.
+ const dead = SHARING_ACCESSIBILITY_STATES.filter(
+ (c) => c.keyboardTarget === null,
+ );
+ expect(dead.map((c) => c.state)).toEqual(["loading"]);
+ });
+});
+
+describe("every state is claimed by a browser test that examines it", () => {
+ const spec = readFileSync(A11Y_SPEC, "utf8");
+ const bodies = testBodies(spec);
+
+ it("parses a non-zero number of tests out of the spec", () => {
+ // The pin the legs below rest on: an empty map satisfies "no state is
+ // missing a helper call" perfectly.
+ expect(bodies.size).toBeGreaterThanOrEqual(
+ SHARING_ACCESSIBILITY_STATES.length,
+ );
+ });
+
+ for (const c of SHARING_ACCESSIBILITY_STATES) {
+ describe(c.state, () => {
+ const body = [...bodies.entries()].find(([title]) =>
+ title.includes(`[${c.state}]`),
+ )?.[1];
+
+ it("has a test that names the state", () => {
+ expect(
+ body,
+ `no test title carries [${c.state}] in ${A11Y_SPEC}`,
+ ).toBeDefined();
+ });
+
+ it("enters the state before it asserts anything about it", () => {
+ expect(body ?? "", c.anchor).toContain(c.anchor);
+ });
+
+ it("scans it and proves the reading order", () => {
+ expect(body ?? "").toContain(SCAN);
+ expect(body ?? "").toContain(READER_ORDER);
+ });
+
+ it(
+ c.keyboardTarget === null
+ ? "announces itself, having nothing to press"
+ : "puts its way forward within reach of a keyboard",
+ () => {
+ if (c.keyboardTarget === null) {
+ expect(body ?? "").toContain(ANNOUNCED);
+ expect(body ?? "").not.toContain(KEYBOARD);
+ } else {
+ expect(body ?? "").toContain(KEYBOARD);
+ expect(body ?? "").toContain(c.keyboardTarget);
+ }
+ },
+ );
+
+ it(
+ c.contrastTarget === null
+ ? "has no visible copy to check"
+ : "renders its material copy as content, not as muted meta",
+ () => {
+ if (c.contrastTarget === null) {
+ expect(body ?? "").not.toContain(CONTRAST);
+ } else {
+ expect(body ?? "").toContain(CONTRAST);
+ expect(body ?? "").toContain(c.contrastTarget);
+ }
+ },
+ );
+ });
+ }
+
+ it("defines every helper it demands", () => {
+ // Otherwise the checks above are satisfied by a call to a function that
+ // does not exist, which typechecks nowhere this guard can see.
+ for (const helper of [SCAN, READER_ORDER, KEYBOARD, ANNOUNCED, CONTRAST]) {
+ expect(spec, helper).toMatch(
+ new RegExp(`(async\\s+function|const)\\s+${helper}\\b`),
+ );
+ }
+ });
+});
+
+describe("the eight release journeys are enumerated", () => {
+ it("names exactly eight, with distinct ids", () => {
+ expect(V137_E2E_JOURNEYS).toHaveLength(8);
+ expect(new Set(V137_E2E_JOURNEYS.map((j) => j.id)).size).toBe(8);
+ for (const journey of V137_E2E_JOURNEYS) {
+ expect(journey.claim.length, journey.id).toBeGreaterThan(40);
+ }
+ });
+});
diff --git a/src/components/ui/query-error-card.tsx b/src/components/ui/query-error-card.tsx
index facb42628..0104b260b 100644
--- a/src/components/ui/query-error-card.tsx
+++ b/src/components/ui/query-error-card.tsx
@@ -48,7 +48,16 @@ export function QueryErrorCard({
{description}
) : null}
{onRetry ? (
-
)}
diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts
index 41a4d2fb7..ae3d479e4 100644
--- a/src/lib/openapi/routes/account-sharing.ts
+++ b/src/lib/openapi/routes/account-sharing.ts
@@ -210,7 +210,12 @@ const recordActivityResponse = z
.number()
.int()
.describe(
- "How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it beside the rows.",
+ "How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows.",
+ ),
+ truncated: z
+ .boolean()
+ .describe(
+ "True when the read reached its row ceiling, so `entries` is the most recent activity inside the window rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest row read as the beginning. False means the list is everything inside the window.",
),
})
.meta({
diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts
index 2d7cd6fb9..f2db0e1b9 100644
--- a/src/lib/queries/use-account-grants.ts
+++ b/src/lib/queries/use-account-grants.ts
@@ -93,6 +93,15 @@ export interface RecordActivity {
* the list is everything that ever happened.
*/
retentionDays: number;
+ /**
+ * True when the read hit its row ceiling, so the list is the most recent
+ * activity inside the window rather than all of it.
+ *
+ * Two different completeness claims, and both have to be stated: the window
+ * bounds how far back, this bounds how many. A list that silently stopped at
+ * its own cap would let the oldest line read as the beginning.
+ */
+ truncated: boolean;
}
export function useAccountGrants(enabled = true) {
diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts
index d3499c2e5..387217e35 100644
--- a/tests/integration/sharing-audit-actor.test.ts
+++ b/tests/integration/sharing-audit-actor.test.ts
@@ -404,3 +404,94 @@ describe("a refused read is reported as a refused read", () => {
expect(sentence).toBe("Alex sent a mood search this record could not read");
});
});
+
+/**
+ * What the owner's activity feed claims, and what it can actually see.
+ *
+ * Two completeness claims live on this response and the surface states both.
+ * They were both wrong in the same direction — too confident — and in opposite
+ * ways:
+ *
+ * * the copy promised a window ("who entered what stays answerable for N
+ * days") while the query had no date filter at all, so on an instance
+ * whose retention was shortened the view showed rows the sentence said
+ * were gone;
+ * * the query stopped at a hundred rows and said nothing, so an owner who
+ * scrolled to the bottom read the oldest visible line as the beginning.
+ *
+ * Driven against real rows rather than a mocked Prisma, because both claims
+ * are about what the DATABASE returns for a given `where` — a stubbed client
+ * would answer whatever the stub was told and prove neither.
+ */
+describe("the activity feed states its own limits", () => {
+ async function feed() {
+ const { GET } = await import("@/app/api/account/activity/route");
+ const response = await GET();
+ expect(response.status).toBe(200);
+ return (await response.json()) as {
+ data: {
+ entries: { action: string }[];
+ retentionDays: number;
+ truncated: boolean;
+ };
+ };
+ }
+
+ /** One delegated row, back-dated. */
+ async function stampedRow(
+ ownerId: string,
+ delegateId: string,
+ createdAt: Date,
+ action = "measurement.update",
+ ) {
+ await getPrismaClient().auditLog.create({
+ data: { userId: ownerId, actorUserId: delegateId, action, createdAt },
+ });
+ }
+
+ it("does not show a row older than the window it promises", async () => {
+ const { owner, delegate } = await household();
+ await signIn(owner.id);
+
+ const days = (await feed()).data.retentionDays;
+ expect(days).toBeGreaterThan(0);
+
+ const inside = new Date(Date.now() - 60_000);
+ const outside = new Date(Date.now() - (days + 2) * 86_400_000);
+ await stampedRow(owner.id, delegate.id, inside, "measurement.update");
+ await stampedRow(owner.id, delegate.id, outside, "measurement.delete");
+
+ const { data } = await feed();
+ const actions = data.entries.map((e) => e.action);
+ // The positive control matters more than the absence: a filter that
+ // excluded everything would satisfy the second assertion alone.
+ expect(actions).toContain("measurement.update");
+ expect(actions).not.toContain("measurement.delete");
+ });
+
+ it("says when the list is the most recent rows rather than all of them", async () => {
+ const { owner, delegate } = await household();
+ await signIn(owner.id);
+
+ // Under the ceiling: the list IS everything, and says so.
+ for (let i = 0; i < 3; i += 1) {
+ await stampedRow(owner.id, delegate.id, new Date(Date.now() - i * 1000));
+ }
+ const small = await feed();
+ expect(small.data.entries).toHaveLength(3);
+ expect(small.data.truncated).toBe(false);
+
+ // Over it: same shape, different claim.
+ await getPrismaClient().auditLog.createMany({
+ data: Array.from({ length: 120 }, (_, i) => ({
+ userId: owner.id,
+ actorUserId: delegate.id,
+ action: "measurement.update",
+ createdAt: new Date(Date.now() - (i + 10) * 1000),
+ })),
+ });
+ const large = await feed();
+ expect(large.data.entries).toHaveLength(100);
+ expect(large.data.truncated).toBe(true);
+ });
+});
From 5de488c35cfcd509e5a26ec935488b7d1b665c54 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 00:52:25 +0200
Subject: [PATCH 8/9] docs: state the sharing boundary that is actually
enforced
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two sentences promised that a manage grant reaches no settings at all. Both
were true when they were written and stopped being true when the anamnesis
destination opened, and both are in the worst places for a stale promise: one
is the access description generated into the public contract and read by the
native client, the other is the paragraph a delegate meets on a page sharing
does not cover.
Both now name the boundary the code draws — record content versus account
configuration — and say which part of the record a manage grant reaches. What
stays with the account owner is spelled out rather than implied: login, the
second factor, provider connections, tokens, notification routing, module and
threshold configuration, and who else has access.
A guard checks the copy against the classification rather than against itself,
so opening a second destination or closing this one moves the sentence with it.
The contract is regenerated.
---
docs/api/openapi.yaml | 14 ++-
messages/de.json | 2 +-
messages/en.json | 2 +-
messages/es.json | 2 +-
messages/fr.json | 2 +-
messages/it.json | 2 +-
messages/pl.json | 2 +-
src/__tests__/sharing-boundary-copy.test.ts | 119 ++++++++++++++++++++
src/lib/validations/account-sharing.ts | 2 +-
9 files changed, 134 insertions(+), 13 deletions(-)
create mode 100644 src/__tests__/sharing-boundary-copy.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 89fe122e2..86c2b2da4 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -12538,12 +12538,14 @@ 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.
- 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."
+ MANAGE can additionally change and remove entries, including ones the owner wrote, read the
+ generated insights, and record the record's own health background (allergies, family history). The
+ boundary is record content versus account configuration: MANAGE never reaches login, the second
+ factor, provider connections, API tokens, notification routing, module and threshold configuration,
+ 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
diff --git a/messages/de.json b/messages/de.json
index 82318dc1f..b928bc2ac 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -9522,7 +9522,7 @@
},
"unavailable": {
"title": "Gehört nicht zum geteilten Zugriff",
- "description": "Geteilt wird die Gesundheitsakte selbst. Einstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.",
+ "description": "Geteilt wird die Gesundheitsakte selbst, einschließlich der Vorgeschichte unter Anamnese. Kontoeinstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.",
"leave": "Zurück zu meiner Akte"
},
"state": {
diff --git a/messages/en.json b/messages/en.json
index af1f56262..208915163 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -9522,7 +9522,7 @@
},
"unavailable": {
"title": "Not part of shared access",
- "description": "Sharing covers the health record itself. Settings, connected services, notifications and the AI features stay with the person who owns the account.",
+ "description": "Sharing covers the health record itself, including the health background under Anamnesis. Account settings, connected services, notifications and the AI features stay with the person who owns the account.",
"leave": "Back to my record"
},
"state": {
diff --git a/messages/es.json b/messages/es.json
index 49c232874..8cc2cb450 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -9522,7 +9522,7 @@
},
"unavailable": {
"title": "No forma parte del acceso compartido",
- "description": "Se comparte el historial de salud en sí. Los ajustes, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.",
+ "description": "Se comparte el historial de salud en sí, incluidos los antecedentes que hay en Anamnesis. Los ajustes de la cuenta, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.",
"leave": "Volver a mi historial"
},
"state": {
diff --git a/messages/fr.json b/messages/fr.json
index 97cb05c19..6668aba8d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -9522,7 +9522,7 @@
},
"unavailable": {
"title": "Ne fait pas partie de l'accès partagé",
- "description": "Le partage porte sur le dossier de santé lui-même. Les réglages, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.",
+ "description": "Le partage porte sur le dossier de santé lui-même, y compris les antécédents dans Anamnèse. Les réglages du compte, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.",
"leave": "Revenir à mon dossier"
},
"state": {
diff --git a/messages/it.json b/messages/it.json
index c5c97cfc3..e922a3689 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -9522,7 +9522,7 @@
},
"unavailable": {
"title": "Non fa parte dell'accesso condiviso",
- "description": "La condivisione riguarda la cartella sanitaria in sé. Impostazioni, servizi collegati, notifiche e funzioni di IA restano alla persona proprietaria dell'account.",
+ "description": "La condivisione riguarda la cartella sanitaria in sé, compresa l'anamnesi. Le impostazioni dell'account, i servizi collegati, le notifiche e le funzioni di IA restano alla persona proprietaria dell'account.",
"leave": "Torna alla mia cartella"
},
"state": {
diff --git a/messages/pl.json b/messages/pl.json
index f7e6d23a1..1866ce06b 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -9522,7 +9522,7 @@
},
"unavailable": {
"title": "Nie należy do udostępnionego dostępu",
- "description": "Udostępniana jest sama karta zdrowia. Ustawienia, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.",
+ "description": "Udostępniana jest sama karta zdrowia, w tym wywiad zdrowotny w sekcji Anamneza. Ustawienia konta, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.",
"leave": "Wróć do mojej karty"
},
"state": {
diff --git a/src/__tests__/sharing-boundary-copy.test.ts b/src/__tests__/sharing-boundary-copy.test.ts
new file mode 100644
index 000000000..a96aaa9a0
--- /dev/null
+++ b/src/__tests__/sharing-boundary-copy.test.ts
@@ -0,0 +1,119 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { inviteGrantSchema } from "@/lib/validations/account-sharing";
+import {
+ SETTINGS_DESTINATION_INVENTORY,
+ isManageDelegateSettingsDestination,
+} from "@/lib/record-settings/classification";
+
+/**
+ * What the product SAYS a manage grant cannot reach agrees with what it cannot
+ * reach.
+ *
+ * Two sentences make that promise, and both were written when it was true:
+ *
+ * * the `access` field's description in the sharing request schema, which is
+ * generated into `docs/api/openapi.yaml` and read by the native client —
+ * it said MANAGE "never reaches … settings";
+ * * `recordSharing.unavailable.description`, the paragraph a delegate meets
+ * on a page sharing does not cover — it said settings "stay with the
+ * person who owns the account".
+ *
+ * v1.37.0 opened one Settings destination to a manage grant, and both
+ * sentences became false in the most expensive place a sentence can be false:
+ * one is a consent screen, the other is a published contract. A promise that
+ * has quietly stopped holding is worse than no promise, because somebody has
+ * already decided based on it.
+ *
+ * So the copy is checked against the classification rather than against
+ * itself. If a second destination is ever opened, or the anamnesis one is
+ * closed again, the sentence that describes the boundary has to move with it.
+ *
+ * What this cannot check is whether the wording is GOOD. It checks that the
+ * absolute claim is gone, that the boundary is named the way the code draws it
+ * (record content versus account configuration), and that all six locales say
+ * it — which is the part that rots silently.
+ */
+const ROOT = process.cwd();
+const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
+
+/** The published description of the level, off the Zod schema itself. */
+function accessDescription(): string {
+ const shape = (
+ inviteGrantSchema as unknown as {
+ _def: { shape: Record };
+ }
+ )._def.shape;
+ return shape.access?.description ?? "";
+}
+
+function description(locale: string): string {
+ const bundle = JSON.parse(
+ readFileSync(join(ROOT, `messages/${locale}.json`), "utf8"),
+ ) as {
+ recordSharing: { unavailable: { description: string } };
+ };
+ return bundle.recordSharing.unavailable.description;
+}
+
+describe("the published boundary matches the one the code draws", () => {
+ const openDestinations = Object.keys(SETTINGS_DESTINATION_INVENTORY).filter(
+ isManageDelegateSettingsDestination,
+ );
+
+ it("has something to be wrong about", () => {
+ // The pin every leg below rests on. With no manage-reachable destination
+ // the old absolute sentence would have been true and this whole file would
+ // be asserting a style preference.
+ expect(openDestinations).not.toHaveLength(0);
+ expect(openDestinations).toContain("anamnesis");
+ expect(accessDescription().length).toBeGreaterThan(200);
+ });
+
+ it("does not claim a manage grant reaches no settings at all", () => {
+ const published = accessDescription();
+ // The exact clause that went stale, and the shape of any replacement for
+ // it. `settings` may appear — it has to, to say which parts stay with the
+ // owner — but not as part of a blanket denial.
+ expect(published).not.toMatch(/never reaches[^.]*\bsettings\b/i);
+ expect(published).not.toMatch(/no access to settings/i);
+ });
+
+ it("names the boundary the classification actually draws", () => {
+ // Not a synonym check for its own sake: "record content versus account
+ // configuration" IS the rule `isManageDelegateSettingsDestination`
+ // implements, and a description that described some other boundary would
+ // be a second, disagreeing specification.
+ expect(accessDescription()).toMatch(
+ /record content versus account configuration/i,
+ );
+ // And the destination that was opened is named, so a reader of the
+ // contract can tell which part of the record it means.
+ expect(accessDescription()).toMatch(/allergies, family history/i);
+ });
+
+ it("keeps the refusal paragraph honest in all six locales", () => {
+ // The delegate-facing half. It is shown on a page sharing does NOT cover,
+ // so a sentence that over-claims there is read at the exact moment
+ // somebody is working out where the edge is.
+ const missing: string[] = [];
+ for (const locale of LOCALES) {
+ const text = description(locale);
+ expect(text.length, locale).toBeGreaterThan(40);
+ // Every locale has to acknowledge the health background, because that
+ // is the part the old sentence implicitly denied. Matched on the stem
+ // rather than the English spelling, and short enough to survive the
+ // accent: the section is "Anamnèse" in French and "Anamneza" in Polish,
+ // so anything past "anamn" is already language-specific and a matcher
+ // tuned to one of them reports the others as broken.
+ if (!/anamn/i.test(text)) missing.push(locale);
+ // And it must still say the account itself stays with its owner —
+ // dropping that would fix one over-claim by removing the boundary.
+ expect(text, locale).toMatch(/konto|account|compte|cuenta|konta|koncie/i);
+ }
+ expect(missing).toEqual([]);
+ });
+});
diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts
index a6c6315c7..5e47ad567 100644
--- a/src/lib/validations/account-sharing.ts
+++ b/src/lib/validations/account-sharing.ts
@@ -58,7 +58,7 @@ export const inviteGrantSchema = z
.enum(["READ", "WRITE", "MANAGE"])
.default("READ")
.describe(
- "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. 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.",
+ "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. MANAGE can additionally change and remove entries, including ones the owner wrote, read the generated insights, and record the record's own health background (allergies, family history). The boundary is record content versus account configuration: MANAGE never reaches login, the second factor, provider connections, API tokens, notification routing, module and threshold configuration, 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.",
),
/**
* v1.37.0 — which sections of the record the invitation opens.
From 6ae0cc993ffa9fdca56bc5aafa5029cd8755e03b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 01:00:02 +0200
Subject: [PATCH 9/9] fix: close four small gaps around the record surface
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The privacy dashboard re-implemented the audit-retention resolver with its own
default and its own floor. Two implementations of one disclosure, quoted to the
same person on two pages and by the purge job; it uses the shared resolver now.
The record-content branch in the settings gate matched a classification rather
than a page, so a second manage-writable destination would have rendered the
allergy and family-history managers under its own heading without anybody
choosing that. It resolves through a slug map, and the contract test fails if
the map and the classification disagree in either direction — a destination
cannot join the kind without somebody deciding what it renders.
The managed-profile nav filter assumed a guardian grant is always manage. It is
today, which is why the check was easy to leave out; an entry below that level
would have listed destinations the gate refuses.
Interpolation used a replacement string, and String.replace reads $&, $`, $',
$1 and $$ out of one. A display name containing $' rewrote the sentence around
it — and display names land in exactly the sentences that say who did what in
somebody's health record. A replacer function has no $ grammar at all, which
removes the class rather than escaping the members of it somebody thought of.
---
.../managed-record-navigation.test.tsx | 15 ++++
.../record-settings-contract.test.ts | 18 ++++
src/app/api/settings/privacy-summary/route.ts | 20 +++--
.../record-settings-section-gate.test.tsx | 86 ++++++++++++++++++-
.../settings/record-settings-section-gate.tsx | 33 ++++++-
src/components/settings/settings-shell.tsx | 9 +-
.../interpolation-is-literal.test.ts | 83 ++++++++++++++++++
src/lib/i18n/context.tsx | 11 ++-
src/lib/i18n/server-translator.ts | 11 ++-
9 files changed, 272 insertions(+), 14 deletions(-)
create mode 100644 src/lib/i18n/__tests__/interpolation-is-literal.test.ts
diff --git a/src/__tests__/managed-record-navigation.test.tsx b/src/__tests__/managed-record-navigation.test.tsx
index 1f0c56634..5f8fd08d7 100644
--- a/src/__tests__/managed-record-navigation.test.tsx
+++ b/src/__tests__/managed-record-navigation.test.tsx
@@ -151,6 +151,21 @@ describe("managed record navigation", () => {
}
});
+ it("offers a managed entry below MANAGE nothing at all", () => {
+ // A guardian grant is always MANAGE today, which is exactly why the level
+ // check was easy to leave out of the managed branch. Without it a managed
+ // entry that ever arrived below MANAGE would list destinations the section
+ // gate refuses, sending somebody to a page that explains it cannot open.
+ recordRef.value = { kind: "managed", level: "read" };
+ const html = renderSettings();
+
+ for (const section of SETTINGS_SECTIONS) {
+ expect(html, section.slug).not.toContain(
+ `href="/settings/${section.slug}"`,
+ );
+ }
+ });
+
it("keeps the exit control present while administering a managed record", () => {
recordRef.value = { kind: "managed", level: "manage" };
const html = renderToStaticMarkup(
diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts
index 50d3781ab..0e1c2b3d0 100644
--- a/src/__tests__/record-settings-contract.test.ts
+++ b/src/__tests__/record-settings-contract.test.ts
@@ -10,6 +10,7 @@ import {
isManageDelegateSettingsDestination,
SETTINGS_DESTINATION_INVENTORY,
} from "@/lib/record-settings/classification";
+import { RECORD_CONTENT_SECTIONS } from "@/components/settings/record-settings-section-gate";
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations";
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
@@ -114,6 +115,23 @@ describe("record settings contract", () => {
}
});
+ it("gives every record-content destination a page of its own", () => {
+ // Both directions, and the first is the one that matters. `manage-writable`
+ // is a classification; the gate has to know which PAGE each slug is, or a
+ // destination added to the kind would silently inherit the allergy and
+ // family-history managers under its own heading. The map is what says
+ // which, and this is what stops the two drifting apart — a new
+ // manage-writable slug fails here, at the moment it is added, rather than
+ // rendering somebody else's forms.
+ const classified = Object.keys(SETTINGS_DESTINATION_INVENTORY)
+ .filter(isManageDelegateSettingsDestination)
+ .sort();
+ const rendered = Object.keys(RECORD_CONTENT_SECTIONS).sort();
+
+ expect(classified).not.toHaveLength(0);
+ expect(rendered).toEqual(classified);
+ });
+
it("leaves exactly one destination on the manage-writable list", () => {
// The list is short on purpose and a second entry is a consent decision,
// not a classification tidy-up. Pinning the count means a destination
diff --git a/src/app/api/settings/privacy-summary/route.ts b/src/app/api/settings/privacy-summary/route.ts
index 8bc58c600..f7e8fae74 100644
--- a/src/app/api/settings/privacy-summary/route.ts
+++ b/src/app/api/settings/privacy-summary/route.ts
@@ -8,17 +8,25 @@
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
+import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { annotate } from "@/lib/logging/context";
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
export const dynamic = "force-dynamic";
/**
- * Retention windows. These mirror the defaults + env names the cleanup jobs
- * read (`coach-message-cleanup.ts`, `audit-log-cleanup.ts`, the reminder
- * cleanup handlers); read here directly so the dashboard never imports a
- * pg-boss job module. Off-host backup retention is operator infrastructure
- * (not an app setting), so it is disclosed as prose, not a number.
+ * Retention windows, as the privacy dashboard states them.
+ *
+ * The audit window comes from `getAuditLogRetentionDays()` rather than from a
+ * second parse of the same variable. It used to be re-implemented here with
+ * its own default and its own floor, which is two implementations of one
+ * disclosure — and this surface, the sharing panel and the purge job all quote
+ * the number to the same person. A drift between them is a page telling
+ * somebody their data is kept for a length of time it is not.
+ *
+ * The rest still parse locally so the dashboard never imports a pg-boss job
+ * module. Off-host backup retention is operator infrastructure (not an app
+ * setting), so it is disclosed as prose, not a number.
*/
function resolveRetention() {
const intEnv = (name: string, fallback: number, min: number): number => {
@@ -28,7 +36,7 @@ function resolveRetention() {
};
return {
coachMessagesDays: intEnv("COACH_MESSAGE_RETENTION_DAYS", 365, 30),
- auditLogDays: intEnv("AUDIT_LOG_RETENTION_DAYS", 365, 7),
+ auditLogDays: getAuditLogRetentionDays(),
// The push-attempt + mood-dispatch ledgers are a fixed 90-day window.
deliveryLogDays: 90,
};
diff --git a/src/components/settings/__tests__/record-settings-section-gate.test.tsx b/src/components/settings/__tests__/record-settings-section-gate.test.tsx
index 63045a08e..9bac09382 100644
--- a/src/components/settings/__tests__/record-settings-section-gate.test.tsx
+++ b/src/components/settings/__tests__/record-settings-section-gate.test.tsx
@@ -8,7 +8,7 @@ const authRef: {
active: {
accountId: string;
recordKind: "managed" | "shared";
- level: "manage";
+ level: "manage" | "write" | "read";
sections: null;
canWrite: true;
} | null;
@@ -38,10 +38,13 @@ vi.mock("../managed-record-settings-section", () => ({
vi.mock("../managed-integration-status", () => ({
ManagedIntegrationStatus: () => ,
}));
+vi.mock("../record-anamnesis-section", () => ({
+ RecordAnamnesisSection: () => ,
+}));
import { RecordSettingsSectionGate } from "../record-settings-section-gate";
-function render(section: "modules" | "integrations") {
+function render(section: "modules" | "integrations" | "anamnesis" | "labs") {
return renderToStaticMarkup(
@@ -77,4 +80,83 @@ describe("RecordSettingsSectionGate", () => {
expect(html).toContain("managed-settings");
expect(html).not.toContain("actor-settings");
});
+
+ /**
+ * The record-content destination, and the two ways it must not open.
+ *
+ * The branch keys on the SLUG as well as on the classification. Keying on
+ * the kind alone would render the allergy and family-history managers under
+ * the heading of any future `manage-writable` destination — silently,
+ * because a category matched where a page was meant.
+ */
+ describe("the anamnesis destination", () => {
+ it("opens for an adult manager on an ordinary shared record", () => {
+ authRef.isLoading = false;
+ authRef.active = {
+ accountId: "shared-record",
+ recordKind: "shared",
+ level: "manage",
+ sections: null,
+ canWrite: true,
+ };
+
+ const html = render("anamnesis");
+
+ expect(html).toContain("record-anamnesis");
+ expect(html).not.toContain("actor-settings");
+ expect(html).not.toContain("managed-settings");
+ });
+
+ it("opens for a Guardian, rather than the guardian configuration panel", () => {
+ authRef.isLoading = false;
+ authRef.active = {
+ accountId: "managed-record",
+ recordKind: "managed",
+ level: "manage",
+ sections: null,
+ canWrite: true,
+ };
+
+ const html = render("anamnesis");
+
+ expect(html).toContain("record-anamnesis");
+ expect(html).not.toContain("managed-settings");
+ });
+
+ it("stays shut below MANAGE", () => {
+ authRef.isLoading = false;
+ authRef.active = {
+ accountId: "shared-record",
+ recordKind: "shared",
+ level: "write",
+ sections: null,
+ canWrite: true,
+ };
+
+ const html = render("anamnesis");
+
+ expect(html).not.toContain("record-anamnesis");
+ expect(html).toContain("shared-record-settings-unavailable-title");
+ });
+
+ it("does not lend its page to another destination of the same kind", () => {
+ // `labs` is `adult-shared-unavailable` today, so this passes for the
+ // right reason now; the claim it pins is that the branch asks WHICH
+ // page, not which category. A second `manage-writable` slug added
+ // without an arm falls through to the refusal panel.
+ authRef.isLoading = false;
+ authRef.active = {
+ accountId: "shared-record",
+ recordKind: "shared",
+ level: "manage",
+ sections: null,
+ canWrite: true,
+ };
+
+ const html = render("labs");
+
+ expect(html).not.toContain("record-anamnesis");
+ expect(html).toContain("shared-record-settings-unavailable-title");
+ });
+ });
});
diff --git a/src/components/settings/record-settings-section-gate.tsx b/src/components/settings/record-settings-section-gate.tsx
index 449c55bf6..aaf697477 100644
--- a/src/components/settings/record-settings-section-gate.tsx
+++ b/src/components/settings/record-settings-section-gate.tsx
@@ -14,6 +14,20 @@ import { RecordAnamnesisSection } from "@/components/settings/record-anamnesis-s
import { SettingsCard } from "@/components/settings/settings-card";
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
+/**
+ * The record-CONTENT destinations, and what each one renders inside a record.
+ *
+ * Keyed by slug so the branch below asks which page it is on rather than which
+ * category the page belongs to. Every `manage-writable` slug must have an
+ * entry and nothing else may: the contract test holds both directions, which
+ * is what stops a new destination inheriting this one's forms.
+ */
+export const RECORD_CONTENT_SECTIONS: Partial<
+ Record ReactNode>
+> = {
+ anamnesis: RecordAnamnesisSection,
+};
+
const MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION = {
account: "profile",
modules: "modules",
@@ -65,9 +79,22 @@ export function RecordSettingsSectionGate({
// Checked before the managed branches so a Guardian gets the same record
// surface an adult manager does, rather than the guardian configuration
// panel, which has no family for it.
- if (destination.kind === "manage-writable" && level === "manage") {
- return ;
- }
+ //
+ // Resolved through a slug map rather than by matching the kind, and that is
+ // not belt-and-braces. `manage-writable` is a classification, not a
+ // component: a second destination joining it — a future record-content page
+ // — would otherwise render the allergy and family-history managers under its
+ // own heading, silently, because the branch matched a category where a page
+ // was meant. `RECORD_CONTENT_SECTIONS` says which page each slug is, a slug
+ // with no entry falls through to the refusal panel below, and
+ // `record-settings-contract.test.ts` fails if the map and the classification
+ // ever disagree — so a destination cannot join the kind without somebody
+ // deciding what it renders.
+ const RecordContent =
+ level === "manage" && destination.kind === "manage-writable"
+ ? RECORD_CONTENT_SECTIONS[section]
+ : undefined;
+ if (RecordContent) return ;
if (
recordKind === "managed" &&
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 7d1a03655..15d3f9335 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -460,10 +460,17 @@ export function SettingsShell({
? allVisibleSections
: allVisibleSections.filter((section) => {
const kind = classifySettingsDestination(section.slug).kind;
+ // Every destination on either list needs MANAGE, so the level is
+ // checked once for both record kinds rather than only for the adult
+ // one. A guardian grant is always MANAGE today, which is exactly why
+ // the check was easy to leave out — and a managed entry that ever
+ // arrived below it would have listed destinations the section gate
+ // refuses, sending somebody to a page that explains it cannot open.
+ if (activeRecord.level !== "manage") return false;
if (activeRecord.recordKind === "managed") {
return kind === "managed-guardian" || kind === "manage-writable";
}
- return kind === "manage-writable" && activeRecord.level === "manage";
+ return kind === "manage-writable";
});
const activeSection = allVisibleSections.find(
(section) => section.slug === activeSlug,
diff --git a/src/lib/i18n/__tests__/interpolation-is-literal.test.ts b/src/lib/i18n/__tests__/interpolation-is-literal.test.ts
new file mode 100644
index 000000000..16459558a
--- /dev/null
+++ b/src/lib/i18n/__tests__/interpolation-is-literal.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+
+/**
+ * An interpolated value is substituted literally, whatever is in it.
+ *
+ * ## The defect
+ *
+ * Both translators filled `{param}` with
+ * `value.replace(new RegExp("\\{k\\}", "g"), String(v))`, and a replacement
+ * STRING is not literal text: `String.prototype.replace` reads `$&`, `` $` ``,
+ * `$'`, `$1` and `$$` out of it. So a value containing `$'` splices in
+ * everything that followed the placeholder, `` $` `` splices in everything
+ * before it, and `$&` repeats the placeholder itself. The sentence a person
+ * reads is then not the sentence the bundle holds.
+ *
+ * Every value that reaches this path is user-controlled, and the ones that
+ * matter most are the ones this release added: a display name lands in
+ * "{name} removed a reading" and in "{name} tried to change a dose and was
+ * refused", on the panel where an owner works out what somebody did in their
+ * health record. A name of `$'` turns "Alex removed a reading" into a sentence
+ * with the wrong subject and no way for the reader to tell.
+ *
+ * Not an injection in the XSS sense — the result is React text children, and
+ * this repository ships no markdown renderer for exactly that reason. It is a
+ * correctness and honesty defect in an audit-facing sentence, which on this
+ * surface is the more expensive kind.
+ *
+ * ## The fix, and why the test is written this way
+ *
+ * A replacer FUNCTION. Its return value is used verbatim and has no `$`
+ * grammar at all, which removes the whole class rather than escaping the
+ * members of it somebody thought of.
+ *
+ * The cases below are the five `$` forms plus a plain control, and each
+ * asserts the WHOLE sentence rather than that it contains the value: a
+ * splice adds text without removing the value, so "contains" would pass on
+ * every one of them.
+ */
+describe("a translated sentence survives its own parameters", () => {
+ const t = getServerTranslator("en").t;
+ // A real key with one placeholder, and it is one of the ones this release
+ // added — the sharing activity line an owner reads about somebody else.
+ const KEY = "recordSharing.activityVerb.measurementDelete";
+
+ it("has a template with exactly one placeholder", () => {
+ // The pin. A key whose text lost its `{name}` would make every case below
+ // pass by never substituting anything.
+ const raw = t(KEY);
+ expect(raw).toContain("{name}");
+ expect(raw.match(/\{name\}/g)).toHaveLength(1);
+ expect(t(KEY, { name: "Alex" })).toBe("Alex removed a reading");
+ });
+
+ const hostile = [
+ { label: "the trailing-context form", value: "$'" },
+ { label: "the leading-context form", value: "$`" },
+ { label: "the whole-match form", value: "$&" },
+ { label: "a capture reference", value: "$1" },
+ { label: "an escaped dollar", value: "$$" },
+ { label: "a plain name, as the control", value: "Alex" },
+ ] as const;
+
+ for (const { label, value } of hostile) {
+ it(`substitutes ${label} verbatim`, () => {
+ expect(t(KEY, { name: value })).toBe(`${value} removed a reading`);
+ });
+ }
+
+ it("substitutes every occurrence, and only the named placeholder", () => {
+ // The `g` flag is load-bearing for keys that name a value twice, and the
+ // replacer must not change that.
+ const both = getServerTranslator("en").t("recordSharing.activity.opened", {
+ name: "$'",
+ count: 3,
+ });
+ expect(both).toContain("$'");
+ expect(both).toContain("3");
+ expect(both).not.toContain("{name}");
+ expect(both).not.toContain("{count}");
+ });
+});
diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx
index b46e57dd6..45149980c 100644
--- a/src/lib/i18n/context.tsx
+++ b/src/lib/i18n/context.tsx
@@ -290,7 +290,16 @@ export function I18nProvider({
// Replace {param} placeholders
if (params) {
for (const [k, v] of Object.entries(params)) {
- value = value.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
+ // A REPLACER FUNCTION, not a replacement string. `String.replace`
+ // reads `$&`, `$\``, `$'`, `$1` and `$$` out of a replacement
+ // STRING, so a value carrying one of them rewrites the sentence
+ // around it: a display name of `$\'` splices everything after the
+ // placeholder back in, and `$\`` splices everything before it. Every
+ // interpolated value here is user-controlled — a person's display
+ // name is the common one, and it lands in sentences on the sharing
+ // panel that describe who did what. A function replacement is
+ // substituted verbatim and has no `$` grammar at all.
+ value = value.replace(new RegExp(`\\{${k}\\}`, "g"), () => String(v));
}
}
diff --git a/src/lib/i18n/server-translator.ts b/src/lib/i18n/server-translator.ts
index 950e4d17f..096d9f8da 100644
--- a/src/lib/i18n/server-translator.ts
+++ b/src/lib/i18n/server-translator.ts
@@ -24,7 +24,16 @@ export function getServerTranslator(locale: Locale): ServerTranslator {
if (value === undefined) return key;
if (params) {
for (const [k, v] of Object.entries(params)) {
- value = value.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
+ // A REPLACER FUNCTION, not a replacement string. `String.replace`
+ // reads `$&`, `$\``, `$'`, `$1` and `$$` out of a replacement
+ // STRING, so a value carrying one of them rewrites the sentence
+ // around it: a display name of `$\'` splices everything after the
+ // placeholder back in, and `$\`` splices everything before it. Every
+ // interpolated value here is user-controlled — a person's display
+ // name is the common one, and it lands in sentences on the sharing
+ // panel that describe who did what. A function replacement is
+ // substituted verbatim and has no `$` grammar at all.
+ value = value.replace(new RegExp(`\\{${k}\\}`, "g"), () => String(v));
}
}
return value;