mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #750 from MBombeck/fix/polish-batch-a
Polish: mood slider, form focus, settings order, sharing scope and expiry
This commit is contained in:
32 files changed
+460
-192
No files matched your search
@@ -2,6 +2,29 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- The mood sliders no longer read their value fused with the reset control, so a
|
||||
slider set to 4 shows the number on its own instead of "4 leeren". The reset
|
||||
is a small icon beside the value now.
|
||||
- Date and time fields wear the same focus highlight as every other input. The
|
||||
ring hugs the field instead of painting wider than it or jumping to the left
|
||||
when you click in, and the fields no longer set off the zoom on iOS.
|
||||
- The add-mood sheet keeps its scrollbar clear of the endpoint labels, so the
|
||||
longest wording is never clipped by the scroll track.
|
||||
- The health-score configuration lists its topics as one flat set with dividers
|
||||
instead of grouping them under headings.
|
||||
- In Settings, under Security, password reset now sits last, after the
|
||||
authenticator app, security keys and passkeys.
|
||||
- Sharing a record again with someone whose access had expired works. The old
|
||||
lapsed grant no longer blocks a fresh invitation with a false report that the
|
||||
person still has access.
|
||||
- The scope options when inviting someone start with nothing preselected, so the
|
||||
choice between the whole record and single sections is one you make rather than
|
||||
one already made for you.
|
||||
- The write and manage descriptions on the invite form are one line each now,
|
||||
with the full detail behind the info icon instead of a wall of text.
|
||||
|
||||
## [1.37.2] — 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -191,6 +191,16 @@ test.describe("account sharing", () => {
|
||||
'[data-slot="grant-invite-identifier"]',
|
||||
);
|
||||
const submit = ownerPage.locator('[data-slot="grant-invite-submit"]');
|
||||
|
||||
// Nothing under the scope question is preselected now, so the form blocks
|
||||
// until the owner picks one. This invitation is unscoped, so choose the
|
||||
// whole record before asserting the button can enable.
|
||||
const wholeRecord = ownerPage.locator(
|
||||
'[data-slot="grant-invite-scope-option"][data-scope="all"]',
|
||||
);
|
||||
await wholeRecord.click();
|
||||
await expect(wholeRecord).toHaveAttribute("data-selected", "true");
|
||||
|
||||
await expect(async () => {
|
||||
await identifier.fill(E2E_USER.username);
|
||||
await expect(submit).toBeEnabled({ timeout: 1000 });
|
||||
|
||||
@@ -106,6 +106,15 @@ test.describe("delegated writes", () => {
|
||||
'[data-slot="grant-invite-identifier"]',
|
||||
);
|
||||
const submit = ownerPage.locator('[data-slot="grant-invite-submit"]');
|
||||
|
||||
// The scope question preselects nothing, so the form blocks until the owner
|
||||
// picks one. This invitation is unscoped — choose the whole record.
|
||||
const wholeRecord = ownerPage.locator(
|
||||
'[data-slot="grant-invite-scope-option"][data-scope="all"]',
|
||||
);
|
||||
await wholeRecord.click();
|
||||
await expect(wholeRecord).toHaveAttribute("data-selected", "true");
|
||||
|
||||
// The controlled input keeps the submit disabled until React has attached,
|
||||
// so retry the pair rather than waiting a fixed time and hoping.
|
||||
await expect(async () => {
|
||||
|
||||
@@ -130,14 +130,13 @@ test.describe("Settings → Health Score", () => {
|
||||
1,
|
||||
);
|
||||
|
||||
// The cardiometabolic triple, visibly under one heading.
|
||||
// The cardiometabolic triple. The list is flat (no group headings) — the
|
||||
// rows carry their domain, so the three cardiometabolic pillars are found
|
||||
// on the row itself.
|
||||
const cardio = page.locator(
|
||||
'[data-slot="score-config-group"][data-domain="cardiometabolic"]',
|
||||
);
|
||||
await expect(cardio).toHaveCount(1);
|
||||
await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount(
|
||||
3,
|
||||
'[data-slot="score-pillar-row"][data-domain="cardiometabolic"]',
|
||||
);
|
||||
await expect(cardio).toHaveCount(3);
|
||||
|
||||
// The rows say nothing about recording or showing — the card says it
|
||||
// once, above them, and the modules screen owns those two questions.
|
||||
|
||||
+5
-9
@@ -5207,13 +5207,6 @@
|
||||
"axis": {
|
||||
"countsFor": "{pillar} in den Score einrechnen"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Herz und Stoffwechsel",
|
||||
"activity": "Aktivität",
|
||||
"sleep": "Schlaf",
|
||||
"adiposity": "Körperform",
|
||||
"wellbeing": "Wohlbefinden"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Zählt in deinen Score",
|
||||
"waiting": "Ausgewählt, wartet auf Daten",
|
||||
@@ -9764,9 +9757,11 @@
|
||||
"accessReadLabel": "Darf lesen",
|
||||
"accessReadCapability": "Sie kann alles lesen, was von dieser Akte geteilt wird. Hinzufügen oder ändern kann sie nichts.",
|
||||
"accessWriteLabel": "Darf lesen und hinzufügen",
|
||||
"accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.",
|
||||
"accessWriteCapability": "Sie kann Einträge hinzufügen, aber nichts ändern oder löschen, auch nicht das, was sie selbst eingetragen hat.",
|
||||
"accessWriteDetail": "Sie kann Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament und ein neues Medikament hinzufügen sowie eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das Eigene, und eine deiner Erinnerungen verschieben kann sie nicht. Das kannst nur du.",
|
||||
"accessManageLabel": "Darf verwalten",
|
||||
"accessManageCapability": "Sie kann Einträge in deiner Akte anlegen, ändern und löschen, auch solche, die du selbst erfasst hast, und sie kann die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.",
|
||||
"accessManageCapability": "Sie kann Einträge anlegen, ändern und löschen, auch solche, die du selbst erfasst hast.",
|
||||
"accessManageDetail": "Sie kann außerdem die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.",
|
||||
"manageStepUpNote": "Dafür wird dein zweiter Faktor abgefragt, deshalb geht das nur im Browser.",
|
||||
"scopeLegend": "Was die Person öffnen darf",
|
||||
"scopeAllLabel": "Gesamte Akte",
|
||||
@@ -9778,6 +9773,7 @@
|
||||
"scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.",
|
||||
"scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.",
|
||||
"scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.",
|
||||
"scopeChooseHint": "Wähle, ob die gesamte Akte oder nur einzelne Bereiche geteilt werden.",
|
||||
"expiresLabel": "Zugriff endet am (optional)",
|
||||
"expiresHint": "Leer lassen für Zugriff, der gilt, bis ihn jemand beendet. Ein Datum wird bei jeder Anfrage geprüft, der Zugriff endet also am Ende dieses Tages von selbst.",
|
||||
"submit": "Einladung senden",
|
||||
|
||||
+5
-9
@@ -5207,13 +5207,6 @@
|
||||
"axis": {
|
||||
"countsFor": "Count {pillar} toward the score"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Heart and metabolism",
|
||||
"activity": "Activity",
|
||||
"sleep": "Sleep",
|
||||
"adiposity": "Body shape",
|
||||
"wellbeing": "Wellbeing"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Counting toward your score",
|
||||
"waiting": "Selected, waiting for data",
|
||||
@@ -9764,9 +9757,11 @@
|
||||
"accessReadLabel": "Can view",
|
||||
"accessReadCapability": "They can read everything this record shares. They cannot add or change anything.",
|
||||
"accessWriteLabel": "Can view and add",
|
||||
"accessWriteCapability": "They can also add to the record: readings, lab results, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.",
|
||||
"accessWriteCapability": "They can add to the record but cannot edit or delete anything, not even what they added.",
|
||||
"accessWriteDetail": "They can add readings, lab results, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and mark a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.",
|
||||
"accessManageLabel": "Can manage",
|
||||
"accessManageCapability": "They can add, change and remove entries in your record, including ones you entered yourself, and they can read the insights generated from it. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.",
|
||||
"accessManageCapability": "They can add, change and remove entries, including ones you entered yourself.",
|
||||
"accessManageDetail": "They can also read the insights generated from your record. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.",
|
||||
"manageStepUpNote": "Offering this asks for your second factor, so it can only be done in a browser.",
|
||||
"scopeLegend": "What they can open",
|
||||
"scopeAllLabel": "Entire record",
|
||||
@@ -9778,6 +9773,7 @@
|
||||
"scopeAggregateNote": "The dashboard overview, health score and daily digest only appear when the entire record is shared.",
|
||||
"scopeDocumentsNote": "A document can contain anything. Share this only if they may read all of it.",
|
||||
"scopeEmptyHint": "Pick at least one section, or go back to sharing the entire record.",
|
||||
"scopeChooseHint": "Choose whether to share the entire record or only selected sections.",
|
||||
"expiresLabel": "Access ends on (optional)",
|
||||
"expiresHint": "Leave this empty for access that lasts until somebody ends it. A date here is checked on every request, so access stops on its own at the end of that day.",
|
||||
"submit": "Send invitation",
|
||||
|
||||
+5
-9
@@ -5207,13 +5207,6 @@
|
||||
"axis": {
|
||||
"countsFor": "Contar {pillar} para la puntuación"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Corazón y metabolismo",
|
||||
"activity": "Actividad",
|
||||
"sleep": "Sueño",
|
||||
"adiposity": "Forma corporal",
|
||||
"wellbeing": "Bienestar"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Cuenta para tu puntuación",
|
||||
"waiting": "Seleccionado, esperando datos",
|
||||
@@ -9764,9 +9757,11 @@
|
||||
"accessReadLabel": "Puede ver",
|
||||
"accessReadCapability": "Puede leer todo lo que este historial comparte. No puede añadir ni cambiar nada.",
|
||||
"accessWriteLabel": "Puede ver y añadir",
|
||||
"accessWriteCapability": "También puede añadir a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.",
|
||||
"accessWriteCapability": "Puede añadir a la ficha, pero no puede editar ni borrar nada, ni siquiera lo que añadió.",
|
||||
"accessWriteDetail": "Puede añadir mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento y un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.",
|
||||
"accessManageLabel": "Puede gestionar",
|
||||
"accessManageCapability": "Puede añadir, cambiar y eliminar entradas de tu ficha, incluidas las que has escrito tú, y puede leer los análisis generados a partir de ella. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.",
|
||||
"accessManageCapability": "Puede añadir, cambiar y eliminar entradas, incluidas las que has escrito tú.",
|
||||
"accessManageDetail": "También puede leer los análisis generados a partir de tu ficha. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.",
|
||||
"manageStepUpNote": "Ofrecerlo pide tu segundo factor, así que solo se puede hacer en un navegador.",
|
||||
"scopeLegend": "Qué puede abrir",
|
||||
"scopeAllLabel": "Todo el historial",
|
||||
@@ -9778,6 +9773,7 @@
|
||||
"scopeAggregateNote": "El resumen del panel, la puntuación de salud y el resumen diario solo aparecen cuando se comparte todo el historial.",
|
||||
"scopeDocumentsNote": "Un documento puede contener cualquier cosa. Comparte esta sección solo si esa persona puede leerlo todo.",
|
||||
"scopeEmptyHint": "Elige al menos una sección o vuelve a compartir todo el historial.",
|
||||
"scopeChooseHint": "Elige si compartir todo el historial o solo algunas secciones.",
|
||||
"expiresLabel": "El acceso termina el (opcional)",
|
||||
"expiresHint": "Déjalo vacío para un acceso que dure hasta que alguien lo termine. Una fecha aquí se comprueba en cada petición, así que el acceso se detiene solo al final de ese día.",
|
||||
"submit": "Enviar invitación",
|
||||
|
||||
+5
-9
@@ -5207,13 +5207,6 @@
|
||||
"axis": {
|
||||
"countsFor": "Compter {pillar} dans le score"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Cœur et métabolisme",
|
||||
"activity": "Activité",
|
||||
"sleep": "Sommeil",
|
||||
"adiposity": "Silhouette",
|
||||
"wellbeing": "Bien-être"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Compte dans votre score",
|
||||
"waiting": "Sélectionné, en attente de données",
|
||||
@@ -9764,9 +9757,11 @@
|
||||
"accessReadLabel": "Peut consulter",
|
||||
"accessReadCapability": "Elle peut lire tout ce que ce dossier partage. Elle ne peut rien ajouter ni rien modifier.",
|
||||
"accessWriteLabel": "Peut consulter et ajouter",
|
||||
"accessWriteCapability": "Elle peut aussi ajouter au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.",
|
||||
"accessWriteCapability": "Elle peut ajouter au dossier, mais ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté.",
|
||||
"accessWriteDetail": "Elle peut ajouter des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament et un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.",
|
||||
"accessManageLabel": "Peut gérer",
|
||||
"accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées de votre dossier, y compris celles que vous avez saisies vous-même, et lire les analyses qui en sont tirées. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.",
|
||||
"accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées, y compris celles que vous avez saisies vous-même.",
|
||||
"accessManageDetail": "Elle peut aussi lire les analyses tirées de votre dossier. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.",
|
||||
"manageStepUpNote": "Cette offre demande votre deuxième facteur, elle n'est donc possible que dans un navigateur.",
|
||||
"scopeLegend": "Ce que cette personne peut ouvrir",
|
||||
"scopeAllLabel": "Dossier complet",
|
||||
@@ -9778,6 +9773,7 @@
|
||||
"scopeAggregateNote": "La vue d'ensemble du tableau de bord, le score de santé et le résumé quotidien n'apparaissent que si le dossier entier est partagé.",
|
||||
"scopeDocumentsNote": "Un document peut contenir n'importe quoi. Ne partagez cette rubrique que si la personne peut tout lire.",
|
||||
"scopeEmptyHint": "Choisissez au moins une rubrique, ou revenez au partage du dossier complet.",
|
||||
"scopeChooseHint": "Choisissez de partager le dossier complet ou seulement certaines rubriques.",
|
||||
"expiresLabel": "L'accès prend fin le (facultatif)",
|
||||
"expiresHint": "Laissez ce champ vide pour un accès qui dure jusqu'à ce que quelqu'un y mette fin. Une date ici est vérifiée à chaque requête, l'accès s'arrête donc de lui-même à la fin de cette journée.",
|
||||
"submit": "Envoyer l'invitation",
|
||||
|
||||
+5
-9
@@ -5207,13 +5207,6 @@
|
||||
"axis": {
|
||||
"countsFor": "Conta {pillar} nel punteggio"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Cuore e metabolismo",
|
||||
"activity": "Attività",
|
||||
"sleep": "Sonno",
|
||||
"adiposity": "Forma corporea",
|
||||
"wellbeing": "Benessere"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Conta nel tuo punteggio",
|
||||
"waiting": "Selezionato, in attesa di dati",
|
||||
@@ -9764,9 +9757,11 @@
|
||||
"accessReadLabel": "Può vedere",
|
||||
"accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.",
|
||||
"accessWriteLabel": "Può vedere e aggiungere",
|
||||
"accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.",
|
||||
"accessWriteCapability": "Può aggiungere alla cartella, ma non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto.",
|
||||
"accessWriteDetail": "Può aggiungere misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco e un nuovo farmaco, e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.",
|
||||
"accessManageLabel": "Può gestire",
|
||||
"accessManageCapability": "Può aggiungere, modificare ed eliminare voci della tua cartella, comprese quelle inserite da te, e può leggere le analisi generate a partire da essa. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.",
|
||||
"accessManageCapability": "Può aggiungere, modificare ed eliminare voci, comprese quelle inserite da te.",
|
||||
"accessManageDetail": "Può anche leggere le analisi generate dalla tua cartella. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.",
|
||||
"manageStepUpNote": "Per offrirlo viene richiesto il tuo secondo fattore, quindi si può fare solo da browser.",
|
||||
"scopeLegend": "Che cosa può aprire",
|
||||
"scopeAllLabel": "Intera cartella",
|
||||
@@ -9778,6 +9773,7 @@
|
||||
"scopeAggregateNote": "La panoramica della dashboard, il punteggio di salute e il riepilogo giornaliero compaiono solo se viene condivisa l'intera cartella.",
|
||||
"scopeDocumentsNote": "Un documento può contenere qualsiasi cosa. Condividi questa sezione solo se quella persona può leggere tutto.",
|
||||
"scopeEmptyHint": "Scegli almeno una sezione oppure torna a condividere l'intera cartella.",
|
||||
"scopeChooseHint": "Scegli se condividere l'intera cartella o solo alcune sezioni.",
|
||||
"expiresLabel": "L'accesso termina il (facoltativo)",
|
||||
"expiresHint": "Lascia vuoto per un accesso che dura finché qualcuno non lo termina. Una data qui viene controllata a ogni richiesta, quindi l'accesso si interrompe da solo alla fine di quel giorno.",
|
||||
"submit": "Invia invito",
|
||||
|
||||
+5
-9
@@ -5207,13 +5207,6 @@
|
||||
"axis": {
|
||||
"countsFor": "Licz {pillar} do wyniku"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Serce i metabolizm",
|
||||
"activity": "Aktywność",
|
||||
"sleep": "Sen",
|
||||
"adiposity": "Sylwetka",
|
||||
"wellbeing": "Samopoczucie"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Liczy się do Twojego wyniku",
|
||||
"waiting": "Wybrany, czeka na dane",
|
||||
@@ -9764,9 +9757,11 @@
|
||||
"accessReadLabel": "Może przeglądać",
|
||||
"accessReadCapability": "Może czytać wszystko, co ta karta udostępnia. Nie może niczego dodać ani zmienić.",
|
||||
"accessWriteLabel": "Może przeglądać i dodawać",
|
||||
"accessWriteCapability": "Może też dodawać do kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.",
|
||||
"accessWriteCapability": "Może dodawać do kartoteki, ale nie może niczego edytować ani usuwać, nawet tego, co sama dodała.",
|
||||
"accessWriteDetail": "Może dodawać pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku i nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.",
|
||||
"accessManageLabel": "Może zarządzać",
|
||||
"accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy w Twojej kartotece, także te, które wprowadziłeś samodzielnie, oraz czytać wygenerowane na jej podstawie analizy. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.",
|
||||
"accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy, także te, które wprowadziłeś samodzielnie.",
|
||||
"accessManageDetail": "Może też czytać analizy wygenerowane na podstawie Twojej kartoteki. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.",
|
||||
"manageStepUpNote": "Przy takim zaproszeniu prosimy o drugi składnik logowania, dlatego można to zrobić tylko w przeglądarce.",
|
||||
"scopeLegend": "Co ta osoba może otworzyć",
|
||||
"scopeAllLabel": "Cała kartoteka",
|
||||
@@ -9778,6 +9773,7 @@
|
||||
"scopeAggregateNote": "Przegląd na pulpicie, wynik zdrowia i dzienne podsumowanie pojawiają się tylko wtedy, gdy udostępniona jest cała kartoteka.",
|
||||
"scopeDocumentsNote": "Dokument może zawierać wszystko. Udostępnij tę sekcję tylko wtedy, gdy ta osoba może przeczytać całość.",
|
||||
"scopeEmptyHint": "Wybierz co najmniej jedną sekcję albo wróć do udostępniania całej kartoteki.",
|
||||
"scopeChooseHint": "Wybierz, czy udostępnić całą kartotekę, czy tylko wybrane sekcje.",
|
||||
"expiresLabel": "Dostęp kończy się (opcjonalnie)",
|
||||
"expiresHint": "Zostaw puste, aby dostęp trwał, dopóki ktoś go nie zakończy. Data jest sprawdzana przy każdym żądaniu, więc dostęp kończy się sam z końcem tego dnia.",
|
||||
"submit": "Wyślij zaproszenie",
|
||||
|
||||
@@ -353,8 +353,9 @@ describe("a refused invitation keeps what was typed", () => {
|
||||
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.
|
||||
// where a `setNarrowed(false)` / `setNarrowed(true)` on a radio would
|
||||
// satisfy the matcher for the wrong reason. The success reset returns the
|
||||
// scope to unset (`null`), which the JSX never writes.
|
||||
const end = INVITE_CARD.indexOf("\n },", split);
|
||||
const onSuccess = INVITE_CARD.slice(start, split);
|
||||
const onError = INVITE_CARD.slice(split, end);
|
||||
@@ -367,7 +368,7 @@ describe("a refused invitation keeps what was typed", () => {
|
||||
'setIdentifier("")',
|
||||
'setExpiresOn("")',
|
||||
'setAccess("READ")',
|
||||
"setNarrowed(false)",
|
||||
"setNarrowed(null)",
|
||||
"setSections([])",
|
||||
]) {
|
||||
expect(onSuccess, reset).toContain(reset);
|
||||
|
||||
@@ -38,7 +38,12 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
accountGrant: { create: vi.fn() },
|
||||
accountGrant: {
|
||||
create: vi.fn(),
|
||||
// inviteGrant sweeps expired live-pair rows before the create; nothing to
|
||||
// close in these fixtures.
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -43,7 +43,12 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
accountGrant: { create: vi.fn() },
|
||||
accountGrant: {
|
||||
create: vi.fn(),
|
||||
// inviteGrant sweeps expired live-pair rows before the create. No such
|
||||
// row exists in these unit fixtures, so it closes nothing.
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -72,14 +72,30 @@ describe("change-password card placement", () => {
|
||||
expect(render(<PasswordCard />)).toContain(SLOT);
|
||||
});
|
||||
|
||||
it("is mounted by Settings → Security before the factor status lands", () => {
|
||||
it("is mounted by Settings → Security even before the factor status lands", () => {
|
||||
// The mocked query never resolves, which is the case that matters: the
|
||||
// MFA read is still in flight (or failing) and the password card is
|
||||
// reachable regardless.
|
||||
// reachable regardless — it sits outside the status conditional.
|
||||
const html = render(<SecuritySection />);
|
||||
expect(html).toContain(SLOT);
|
||||
});
|
||||
|
||||
it("orders the password card last, after the passkey list", () => {
|
||||
// The section reads authenticator → security keys → passkeys → password.
|
||||
// The order lives in one component and the resolved cards only render once
|
||||
// the status query settles (the render mock keeps it in flight), so the
|
||||
// guard reads the source: the password card must come after the passkey
|
||||
// list. Re-reported once as an order regression; this pins it.
|
||||
const source = readFileSync(
|
||||
join(ROOT, "src/components/settings/security-section/index.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const passkeyIdx = source.indexOf("<PasskeyListSection");
|
||||
const passwordIdx = source.indexOf("<PasswordCard");
|
||||
expect(passkeyIdx).toBeGreaterThan(-1);
|
||||
expect(passwordIdx).toBeGreaterThan(passkeyIdx);
|
||||
});
|
||||
|
||||
it("has exactly one home in the tree", () => {
|
||||
const owners = walk(join(ROOT, "src"))
|
||||
.filter((file) => readFileSync(file, "utf8").includes("PasswordCard"))
|
||||
|
||||
@@ -16,11 +16,20 @@
|
||||
* a rewrite that quietly drops it should fail here.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants";
|
||||
|
||||
/** The shipped English invite copy, the source of truth for the consent text. */
|
||||
const inviteBundle = (
|
||||
JSON.parse(readFileSync(join(process.cwd(), "messages/en.json"), "utf8")) as {
|
||||
recordSharing: { invite: Record<string, string> };
|
||||
}
|
||||
).recordSharing.invite;
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: grantList() };
|
||||
/**
|
||||
* A grant payload, with the audit window every real one carries.
|
||||
@@ -110,8 +119,11 @@ describe("offering a level", () => {
|
||||
// second group of radios in the same idiom, and a group that lost the ring
|
||||
// would be the same defect shipped again one section further down.
|
||||
const html = render(<GrantInviteCard />);
|
||||
// Access options carry an (i) sibling now, so the row is a `<div>` with the
|
||||
// radio in a nested `<label>`; scope options stay a wrapping `<label>`. The
|
||||
// focus ring rides the row in both, which is what this pins.
|
||||
const levels = html.match(
|
||||
/<label[^>]*data-slot="grant-invite-access-option"[^>]*>/g,
|
||||
/<div[^>]*data-slot="grant-invite-access-option"[^>]*>/g,
|
||||
);
|
||||
const scopes = html.match(
|
||||
/<label[^>]*data-slot="grant-invite-scope-option"[^>]*>/g,
|
||||
@@ -126,39 +138,54 @@ describe("offering a level", () => {
|
||||
|
||||
it("says plainly that manage can change and delete what the owner wrote", () => {
|
||||
const html = render(<GrantInviteCard />);
|
||||
// The clause that separates this level from the one below it. A rewrite
|
||||
// that keeps "manage" and drops this is a rewrite that stops describing
|
||||
// a delete button.
|
||||
expect(html).toContain("add, change and remove entries in your record");
|
||||
// The clause that separates this level from the one below it, on the label
|
||||
// where somebody decides. A rewrite that keeps "manage" and drops this is a
|
||||
// rewrite that stops describing a delete button.
|
||||
expect(html).toContain("add, change and remove entries");
|
||||
expect(html).toContain("including ones you entered yourself");
|
||||
// And the fence, in the same breath. Consent to management is only
|
||||
// meaningful beside what management does not reach.
|
||||
expect(html).toContain("They cannot change your login");
|
||||
expect(html).toContain("who else has access");
|
||||
expect(html).toContain("You can end this at any time.");
|
||||
// And the fence, in the same breath — now one tap behind the (i) rather
|
||||
// than a wall inline, but still shipped consent copy, not docs. Consent to
|
||||
// management is only meaningful beside what management does not reach.
|
||||
expect(inviteBundle.accessManageDetail).toContain(
|
||||
"They cannot change your login",
|
||||
);
|
||||
expect(inviteBundle.accessManageDetail).toContain("who else has access");
|
||||
expect(inviteBundle.accessManageDetail).toContain(
|
||||
"You can end this at any time.",
|
||||
);
|
||||
});
|
||||
|
||||
it("says what the wider level actually does, and what it does not", () => {
|
||||
const html = render(<GrantInviteCard />);
|
||||
// The thing a person reading "write access" gets wrong.
|
||||
// The thing a person reading "write access" gets wrong, inline on the label.
|
||||
expect(html).toContain("cannot edit or delete anything");
|
||||
expect(html).toContain("marking a dose taken or skipped");
|
||||
// The exact list of what write may add rides the (i) detail now.
|
||||
expect(inviteBundle.accessWriteDetail).toContain(
|
||||
"mark a dose taken or skipped",
|
||||
);
|
||||
// v1.36.1 — the copy named allergies, which left the admitted set before
|
||||
// this shipped, and it did so on the one screen where somebody decides who
|
||||
// may write into their health record. Assert the withdrawal too, not only
|
||||
// the presence of the rest: a consent notice that over-promises is worse
|
||||
// than one that is merely incomplete.
|
||||
// than one that is merely incomplete. Neither the inline line nor the
|
||||
// detail may name it.
|
||||
expect(html).not.toContain("allergies");
|
||||
expect(inviteBundle.accessWriteDetail).not.toContain("allergies");
|
||||
// v1.36.x — and the same again for logging a value on a tracked metric.
|
||||
// The route was admitted, the copy promised it, and no delegate could
|
||||
// reach a surface that posts one: `/custom-metrics/{id}` is not a
|
||||
// shared-record destination, so the shell answers before the form does.
|
||||
// The permission and the sentence went together.
|
||||
expect(html).not.toContain("a metric you track yourself");
|
||||
expect(inviteBundle.accessWriteDetail).not.toContain(
|
||||
"a metric you track yourself",
|
||||
);
|
||||
// Deferring a reminder is the capability the same request could reach and
|
||||
// delegation does not cover; the notice has to say so.
|
||||
expect(html).toContain("cannot defer one of your reminders");
|
||||
// And the narrow option still says it is narrow.
|
||||
expect(inviteBundle.accessWriteDetail).toContain(
|
||||
"cannot defer one of your reminders",
|
||||
);
|
||||
// And the narrow option still says it is narrow, inline.
|
||||
expect(html).toContain("They cannot add or change anything.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
*
|
||||
* Mutation checks, run and recorded in the stream's report:
|
||||
* - render the eight checkboxes unconditionally (drop the `narrowed` gate) →
|
||||
* "the entire record is the default and the sections stay closed" goes red;
|
||||
* "preselects neither scope and leaves the sections closed" goes red;
|
||||
* - preselect a scope (`narrowed` defaulting to a boolean) → the same test
|
||||
* goes red on the `data-selected="false"` assertions;
|
||||
* - render the scope fieldset for MANAGE too → "manage collapses the whole
|
||||
* question" goes red.
|
||||
*/
|
||||
@@ -55,14 +57,15 @@ describe("choosing what a grant opens", () => {
|
||||
expect(html).toContain('data-scope="some"');
|
||||
});
|
||||
|
||||
it("makes the entire record the default and leaves the sections closed", () => {
|
||||
// The shape of the whole design: narrowing is an act somebody chooses, so
|
||||
// the default costs nothing and the eight checkboxes are not on screen
|
||||
// until they are wanted. A form that showed them all at once would make
|
||||
// the common case look like unfinished work.
|
||||
it("preselects neither scope and leaves the sections closed", () => {
|
||||
// Nothing is nudged: whole record and narrowing are both a choice the owner
|
||||
// makes. An all-checked or whole-record default reads as the form answering
|
||||
// the question for them. The eight checkboxes stay off screen until the
|
||||
// narrow branch is chosen, and the form blocks until a scope is picked.
|
||||
const html = render(<GrantInviteCard />);
|
||||
expect(html).toContain('data-scope="all" data-selected="true"');
|
||||
expect(html).toContain('data-scope="all" data-selected="false"');
|
||||
expect(html).toContain('data-scope="some" data-selected="false"');
|
||||
expect(html).toContain('data-slot="grant-invite-scope-choose"');
|
||||
expect(html).not.toContain('data-slot="grant-invite-sections"');
|
||||
expect(html).not.toContain('data-slot="grant-invite-section"');
|
||||
});
|
||||
@@ -131,15 +134,22 @@ describe("the bundle behind the expanded state", () => {
|
||||
});
|
||||
|
||||
it("states the manage fence in the invitation copy itself", () => {
|
||||
// Not in a tooltip and not in the docs: on the label, where somebody is
|
||||
// deciding.
|
||||
// The consent line stays one sentence and inline — what Manage lets someone
|
||||
// do. The full fence (what it does NOT reach) rides the (i) detail rather
|
||||
// than a wall of text, but it is still shipped copy, not docs.
|
||||
expect(invite.accessManageCapability).toContain(
|
||||
"add, change and remove entries",
|
||||
);
|
||||
expect(invite.accessManageCapability).toContain(
|
||||
"including ones you entered yourself",
|
||||
);
|
||||
expect(invite.accessManageCapability).toContain("They cannot change your");
|
||||
// The one-liner is a single sentence, not a paragraph.
|
||||
expect(invite.accessManageCapability.split(". ").length).toBeLessThan(2);
|
||||
expect(invite.accessManageDetail).toContain("They cannot change your");
|
||||
// WRITE is split the same way: a one-line consent sentence, the full
|
||||
// enumeration behind the (i).
|
||||
expect(invite.accessWriteCapability.split(". ").length).toBeLessThan(2);
|
||||
expect(invite.accessWriteDetail).toContain("mark a dose taken or skipped");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +159,16 @@ describe("what the form would send", () => {
|
||||
// the copy above says what a person is agreeing to, this says what leaves
|
||||
// the browser.
|
||||
|
||||
it("sends nothing scoped for the whole record", () => {
|
||||
it("blocks until a scope is chosen, and does not fall through to whole record", () => {
|
||||
// `null` is the unset state. It must NOT resolve to scope-absent: the wire
|
||||
// reads an absent scope as the entire record, so an untouched fieldset that
|
||||
// omitted scope would silently mint a whole-record grant. Unset blocks.
|
||||
expect(
|
||||
resolveInviteScope({ access: "READ", narrowed: null, sections: [] }),
|
||||
).toEqual({ mode: "unset", scope: null, blocked: true });
|
||||
});
|
||||
|
||||
it("sends nothing scoped for the whole record once it is explicitly chosen", () => {
|
||||
expect(
|
||||
resolveInviteScope({ access: "READ", narrowed: false, sections: [] }),
|
||||
).toEqual({ mode: "all", scope: null, blocked: false });
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { InfoPopover } from "@/components/ui/info-popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -42,12 +43,12 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
* * **What they may do** stays what it was, one radio per level, narrowest
|
||||
* preselected, each with the sentence that says what it actually does
|
||||
* rather than the word for it.
|
||||
* * **What they may open** is two radios, whole record preselected. Narrowing
|
||||
* is the branch somebody chooses, and only then do the eight sections
|
||||
* appear. A person who wants the default reads two lines and presses send;
|
||||
* a person who wants to narrow sees at most eight named subjects with a
|
||||
* sentence each, which is a thing a person can hold in their head. Nobody
|
||||
* ever sees nineteen checkboxes.
|
||||
* * **What they may open** is two radios with nothing preselected. Whole
|
||||
* record and narrowing are both a choice the owner makes, so neither is
|
||||
* nudged: an untouched fieldset blocks the form rather than defaulting to
|
||||
* the whole record. Choosing to narrow reveals the eight sections; a person
|
||||
* who wants everything picks whole record and presses send. Nobody ever
|
||||
* sees nineteen checkboxes.
|
||||
*
|
||||
* The two honest limits are stated inside the expanded state, where the
|
||||
* decision is actually being made, rather than in help text somebody would
|
||||
@@ -84,9 +85,11 @@ export function GrantInviteCard() {
|
||||
// READ preselected. The narrower level is the one somebody should have to
|
||||
// choose their way out of, not into.
|
||||
const [access, setAccess] = useState<GrantAccessLevel>("READ");
|
||||
// The entire record preselected, for the same reason and with the opposite
|
||||
// sign: narrowing is the deliberate act, so it is the one that costs a click.
|
||||
const [narrowed, setNarrowed] = useState(false);
|
||||
// Nothing preselected: neither "the entire record" nor "only sections" is
|
||||
// chosen until the owner picks one. `null` is that unset state, and it blocks
|
||||
// the form — a whole-record grant is a real decision, not the thing that
|
||||
// happens when somebody leaves the fieldset alone.
|
||||
const [narrowed, setNarrowed] = useState<boolean | null>(null);
|
||||
const [sections, setSections] = useState<ShareDomain[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [invited, setInvited] = useState<string | null>(null);
|
||||
@@ -133,7 +136,7 @@ export function GrantInviteCard() {
|
||||
setIdentifier("");
|
||||
setExpiresOn("");
|
||||
setAccess("READ");
|
||||
setNarrowed(false);
|
||||
setNarrowed(null);
|
||||
setSections([]);
|
||||
setInvited(grant.account.username);
|
||||
},
|
||||
@@ -188,6 +191,7 @@ export function GrantInviteCard() {
|
||||
onSelect={() => chooseAccess("WRITE")}
|
||||
label={t("recordSharing.invite.accessWriteLabel")}
|
||||
capability={t("recordSharing.invite.accessWriteCapability")}
|
||||
detail={t("recordSharing.invite.accessWriteDetail")}
|
||||
/>
|
||||
<AccessOption
|
||||
level="MANAGE"
|
||||
@@ -195,6 +199,7 @@ export function GrantInviteCard() {
|
||||
onSelect={() => chooseAccess("MANAGE")}
|
||||
label={t("recordSharing.invite.accessManageLabel")}
|
||||
capability={t("recordSharing.invite.accessManageCapability")}
|
||||
detail={t("recordSharing.invite.accessManageDetail")}
|
||||
/>
|
||||
</div>
|
||||
{choice.mode === "manage" && (
|
||||
@@ -224,7 +229,7 @@ export function GrantInviteCard() {
|
||||
<div className="space-y-2">
|
||||
<ScopeOption
|
||||
mode="all"
|
||||
selected={!narrowed}
|
||||
selected={narrowed === false}
|
||||
onSelect={() => {
|
||||
setNarrowed(false);
|
||||
setError(null);
|
||||
@@ -234,7 +239,7 @@ export function GrantInviteCard() {
|
||||
/>
|
||||
<ScopeOption
|
||||
mode="some"
|
||||
selected={narrowed}
|
||||
selected={narrowed === true}
|
||||
onSelect={() => {
|
||||
setNarrowed(true);
|
||||
setError(null);
|
||||
@@ -243,6 +248,16 @@ export function GrantInviteCard() {
|
||||
capability={t("recordSharing.invite.scopeSomeCapability")}
|
||||
/>
|
||||
</div>
|
||||
{choice.mode === "unset" && (
|
||||
// Nothing chosen: say what the two options decide, rather than
|
||||
// sending a request or silently defaulting to the whole record.
|
||||
<p
|
||||
data-slot="grant-invite-scope-choose"
|
||||
className="text-muted-foreground pl-1 text-xs"
|
||||
>
|
||||
{t("recordSharing.invite.scopeChooseHint")}
|
||||
</p>
|
||||
)}
|
||||
{choice.mode === "pick" && (
|
||||
<div
|
||||
data-slot="grant-invite-sections"
|
||||
@@ -364,7 +379,7 @@ export interface InviteScopeChoice {
|
||||
* Which of the three shapes the second question takes: the collapsed line
|
||||
* that answers it for Manage, the whole record, or the section list.
|
||||
*/
|
||||
mode: "manage" | "all" | "pick";
|
||||
mode: "manage" | "unset" | "all" | "pick";
|
||||
/** Exactly what goes on the wire as `scope`. */
|
||||
scope: ShareDomain[] | null;
|
||||
/** Is the form in a state that must not be sent. */
|
||||
@@ -399,12 +414,17 @@ export function resolveInviteScope({
|
||||
sections,
|
||||
}: {
|
||||
access: GrantAccessLevel;
|
||||
narrowed: boolean;
|
||||
narrowed: boolean | null;
|
||||
sections: ShareDomain[];
|
||||
}): InviteScopeChoice {
|
||||
if (access === "MANAGE") {
|
||||
return { mode: "manage", scope: null, blocked: false };
|
||||
}
|
||||
// Nothing chosen yet. This is NOT the whole-record default: scope-absent is
|
||||
// read by the wire as "the entire record" (the pre-v1.37.0 contract), so an
|
||||
// unset UI must block rather than fall through to it. The whole-record grant
|
||||
// is only ever the explicit "all" choice below.
|
||||
if (narrowed === null) return { mode: "unset", scope: null, blocked: true };
|
||||
if (!narrowed) return { mode: "all", scope: null, blocked: false };
|
||||
const picked = SHARE_DOMAINS.filter((domain) => sections.includes(domain));
|
||||
// A narrowed invitation with nothing ticked is not a narrow grant, it is a
|
||||
@@ -414,13 +434,17 @@ export function resolveInviteScope({
|
||||
}
|
||||
|
||||
/**
|
||||
* One level, with the sentence that says what it actually does.
|
||||
* One level, with the one sentence that says what it actually does.
|
||||
*
|
||||
* The capability line is not decoration and it is not a tooltip: it is the
|
||||
* part the owner is consenting to, so it sits under the label where it has to
|
||||
* be read past rather than behind an info icon. Muted because it describes the
|
||||
* option rather than being the option (UI-STANDARDS §3), `text-xs` because
|
||||
* that is the meta floor.
|
||||
* The capability line is the part the owner is consenting to, so it stays
|
||||
* inline under the label rather than behind an icon — but it is one line, not a
|
||||
* wall. Where a level has more to say than fits on a line (the exact list WRITE
|
||||
* may add, the fence MANAGE does NOT reach), that detail rides an `InfoPopover`
|
||||
* at the option-heading height. The (i) sits OUTSIDE the `<label>` on purpose:
|
||||
* a button inside a label forwards its own tap to the label's control, so the
|
||||
* radio and the popover would fight for the same click (the same reason
|
||||
* `SectionOption` avoids the wrapping label). Muted, `text-xs` — the meta floor
|
||||
* per UI-STANDARDS §3.
|
||||
*/
|
||||
function AccessOption({
|
||||
level,
|
||||
@@ -428,37 +452,51 @@ function AccessOption({
|
||||
onSelect,
|
||||
label,
|
||||
capability,
|
||||
detail,
|
||||
}: {
|
||||
level: GrantAccessLevel;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
label: string;
|
||||
capability: string;
|
||||
/** The full enumeration, shown behind the (i) when a line is not enough. */
|
||||
detail?: string;
|
||||
}) {
|
||||
const id = `grant-invite-access-${level}`;
|
||||
return (
|
||||
<label
|
||||
<div
|
||||
data-slot="grant-invite-access-option"
|
||||
data-access={level}
|
||||
data-selected={selected ? "true" : "false"}
|
||||
className={optionClass(selected)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="grant-invite-access"
|
||||
value={level}
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="text-foreground block text-sm font-medium">
|
||||
{label}
|
||||
<label htmlFor={id} className="flex flex-1 cursor-pointer items-start">
|
||||
<input
|
||||
id={id}
|
||||
type="radio"
|
||||
name="grant-invite-access"
|
||||
value={level}
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="text-foreground block text-sm font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{capability}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{capability}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</label>
|
||||
{detail ? (
|
||||
<InfoPopover
|
||||
content={detail}
|
||||
label={label}
|
||||
bodyDataSlot="grant-invite-access-detail"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ScorePillarRow({
|
||||
data-domain={row.domain}
|
||||
data-counts={row.counts ? "true" : "false"}
|
||||
data-eligibility={row.eligibility}
|
||||
className="flex min-h-11 items-center justify-between gap-4"
|
||||
className="flex min-h-11 items-center justify-between gap-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
|
||||
@@ -72,7 +72,6 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { SCORE_DOMAIN_LABEL_KEYS } from "@/lib/score-config/labels";
|
||||
import {
|
||||
buildScoreConfigRows,
|
||||
type ScorePillarVerdict,
|
||||
@@ -387,38 +386,22 @@ export function ScoreSection() {
|
||||
<Skeleton className="h-11 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.domain}
|
||||
data-slot="score-config-group"
|
||||
data-domain={group.domain}
|
||||
className="space-y-2"
|
||||
>
|
||||
{/* Names an area of health, so it sits in the content
|
||||
tier. The muted section-label treatment it shipped
|
||||
with read as switched-off beside the rows it
|
||||
heads. */}
|
||||
<h3
|
||||
data-slot="score-config-group-heading"
|
||||
className="text-foreground text-xs font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t(SCORE_DOMAIN_LABEL_KEYS[group.domain])}
|
||||
</h3>
|
||||
{/* No rules between the rows: the gap between groups
|
||||
is the grouping. */}
|
||||
<div className="space-y-3">
|
||||
{group.rows.map((row) => (
|
||||
<ScorePillarRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pending={busy}
|
||||
onToggle={(next) => toggle(row.id, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
// One flat list of topics separated by hairline dividers, the
|
||||
// way Anamnese lists its facts. The group headings are gone; the
|
||||
// registry order (domain, then pillar) is preserved by flattening
|
||||
// the groups in order, and each row keeps its `data-domain` for
|
||||
// tests and e2e.
|
||||
<div className="divide-border divide-y">
|
||||
{groups.flatMap((group) =>
|
||||
group.rows.map((row) => (
|
||||
<ScorePillarRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pending={busy}
|
||||
onToggle={(next) => toggle(row.id, next)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -50,13 +50,6 @@ export function SecuritySection() {
|
||||
<div className="space-y-6">
|
||||
{showNudge && <PasskeyUpgradeNudge />}
|
||||
|
||||
{/* The password leads, and it renders before the factor status has
|
||||
landed — it depends on nothing this page fetches. It is also the
|
||||
only way to change a password now that the Account card is gone, so
|
||||
a `/api/auth/me/mfa` that never answers must not take it down with
|
||||
the cards that do need it. */}
|
||||
<PasswordCard />
|
||||
|
||||
{isLoading || !status ? (
|
||||
<>
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
@@ -75,6 +68,14 @@ export function SecuritySection() {
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The password comes last: authenticator, then security keys, then
|
||||
passkeys, then the password. It sits OUTSIDE the status conditional
|
||||
on purpose — it depends on nothing this page fetches and is the only
|
||||
way to change a password now that the Account card is gone, so a
|
||||
`/api/auth/me/mfa` that never answers must not take it down with the
|
||||
cards that do need the status. */}
|
||||
<PasswordCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,4 +97,15 @@ describe("<DateField>", () => {
|
||||
const html = render(<DateField value="2026-02-19" />);
|
||||
expect(html).toContain('aria-label="Open date picker"');
|
||||
});
|
||||
|
||||
it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => {
|
||||
// Drift guard: the field must share the app's focus ring (soft 3 px, no
|
||||
// offset) so the highlight never paints wider than the div or jumps on
|
||||
// focus. The old hard `ring-offset-2` ring is banned here.
|
||||
const html = render(<DateField value="2026-02-19" />);
|
||||
expect(html).toContain("focus-within:ring-ring/50");
|
||||
expect(html).toContain("focus-within:ring-[3px]");
|
||||
expect(html).not.toContain("ring-offset-2");
|
||||
expect(html).not.toContain("ring-offset-background");
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,20 @@ describe("<ResponsiveSheet>", () => {
|
||||
expect(html).not.toContain('data-slot="mock-sheet"');
|
||||
});
|
||||
|
||||
it("reclaims a scroll gutter on the Dialog body so the scrollbar clears right-aligned labels", () => {
|
||||
mobile = false;
|
||||
const html = renderToStaticMarkup(
|
||||
<ResponsiveSheet open onOpenChange={() => {}} title="Add mood">
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
// The body carries `-mr-3 pr-3`: a 12 px gutter reclaimed from the dialog's
|
||||
// own padding so the vertical scrollbar lives beside the text, not on the
|
||||
// last characters of a right-aligned slider anchor.
|
||||
expect(html).toContain("-mr-3");
|
||||
expect(html).toContain("pr-3");
|
||||
});
|
||||
|
||||
it("renders the bottom Sheet branch on narrow viewports", () => {
|
||||
mobile = true;
|
||||
const html = renderToStaticMarkup(
|
||||
|
||||
@@ -105,7 +105,11 @@ describe("<SliderField>", () => {
|
||||
it("offers to clear an answer only when there is one to clear", () => {
|
||||
const set = field({ onClear: () => {}, clearLabel: "zurücksetzen" });
|
||||
expect(set).toContain('data-slot="slider-field-clear"');
|
||||
expect(set).toContain("zurücksetzen");
|
||||
// The clear affordance is icon-only: the label rides the accessible name,
|
||||
// not visible text beside the value, so a set slider never reads "4 leeren".
|
||||
expect(set).toContain('aria-label="zurücksetzen"');
|
||||
// The value span holds only the bare number, never the clear-action label.
|
||||
expect(set).toMatch(/data-slot="slider-field-value"[^>]*>7<\/span>/);
|
||||
|
||||
const unanswered = field({
|
||||
value: null,
|
||||
|
||||
@@ -120,4 +120,13 @@ describe("<TimeField> SSR", () => {
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("sm:h-10");
|
||||
});
|
||||
it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => {
|
||||
// Drift guard shared with <DateField>: soft 3 px ring, no offset, so the
|
||||
// highlight never overflows the div or jumps on focus.
|
||||
const html = render(<TimeField value="08:30" />, "de");
|
||||
expect(html).toContain("focus-within:ring-ring/50");
|
||||
expect(html).toContain("focus-within:ring-[3px]");
|
||||
expect(html).not.toContain("ring-offset-2");
|
||||
expect(html).not.toContain("ring-offset-background");
|
||||
});
|
||||
});
|
||||
@@ -56,8 +56,15 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
|
||||
// ~230 px no matter what width the parent hands it, and the surplus spills out
|
||||
// of whatever dialog or sheet it sits in. See the matching note on the overlay
|
||||
// input below; both ends are needed, one alone still floors the box.
|
||||
// The focus vocabulary matches `<Input>` exactly: a soft translucent 3 px ring
|
||||
// that hugs the border box with no offset. The earlier `ring-2 ring-offset-2`
|
||||
// painted a hard 4 px ring OUTSIDE the box — wider than the field's div and
|
||||
// visibly mismatched next to the app's other inputs, and it shifted the
|
||||
// highlight box on focus. `text-base md:text-sm` mirrors `<Input>` too: a
|
||||
// sub-16 px input triggers iOS Safari's focus auto-zoom, which reads as a
|
||||
// layout jump; 16 px below `md:` suppresses it.
|
||||
const FIELD_BASE_CLASSES =
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none";
|
||||
|
||||
export interface DateFieldProps {
|
||||
id?: string;
|
||||
|
||||
@@ -320,7 +320,16 @@ export function ResponsiveSheet({
|
||||
// Content that genuinely needs to scroll sideways (a wide table)
|
||||
// owns its own `overflow-x-auto` wrapper — that is the correct
|
||||
// place for it, and it still works inside a clipped port.
|
||||
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto",
|
||||
//
|
||||
// `-mr-3 pr-3` reclaims a 12 px scroll gutter from the dialog's own
|
||||
// `p-6` padding: the vertical scrollbar would otherwise render flush
|
||||
// against the body's right edge, exactly where a right-aligned child
|
||||
// (a slider's high anchor label) sits, painting on top of the last
|
||||
// characters. The margin gives the padding straight back, so content
|
||||
// alignment is unchanged — the scrollbar just lives beside the text
|
||||
// now, not on it. The mobile Sheet branch has its own `p-4` and
|
||||
// needs nothing.
|
||||
"-mr-3 flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto pr-3",
|
||||
bodyClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -136,9 +137,11 @@ function SliderField({
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
data-slot="slider-field-clear"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-xs underline-offset-2 outline-none hover:underline focus-visible:ring-[3px]"
|
||||
aria-label={clearLabel}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex size-6 items-center justify-center rounded outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{clearLabel}
|
||||
<X className="size-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">{clearLabel}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -42,8 +42,12 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
|
||||
// automatic minimum size would otherwise be the 20-character intrinsic width of
|
||||
// the text input inside, which floors the whole field at ~230 px and overflows
|
||||
// the dialog that hosts it. See the note in `date-field.tsx`.
|
||||
// Focus vocabulary and font-size parity with `<Input>` / `<DateField>`: a soft
|
||||
// 3 px ring hugging the box (not the old hard `ring-2 ring-offset-2`, which
|
||||
// painted 4 px outside the div and jumped on focus) and `text-base md:text-sm`
|
||||
// to keep iOS Safari from auto-zooming a sub-16 px input.
|
||||
const FIELD_BASE_CLASSES =
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none";
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* surface renames a pillar, and the two surfaces would then be talking
|
||||
* about different things while showing the same list.
|
||||
*/
|
||||
import type { ScoreDomain, ScorePillarId } from "@/lib/analytics/score/types";
|
||||
import type { ScorePillarId } from "@/lib/analytics/score/types";
|
||||
|
||||
export const SCORE_PILLAR_LABEL_KEYS: Record<ScorePillarId, string> = {
|
||||
BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure",
|
||||
@@ -17,12 +17,3 @@ export const SCORE_PILLAR_LABEL_KEYS: Record<ScorePillarId, string> = {
|
||||
WELLBEING: "insights.healthScore.pillar.wellbeing",
|
||||
LIPIDS: "insights.healthScore.pillar.lipids",
|
||||
};
|
||||
|
||||
/** Domain headings, one per distinct area of health. */
|
||||
export const SCORE_DOMAIN_LABEL_KEYS: Record<ScoreDomain, string> = {
|
||||
cardiometabolic: "settings.sections.score.domain.cardiometabolic",
|
||||
activity: "settings.sections.score.domain.activity",
|
||||
sleep: "settings.sections.score.domain.sleep",
|
||||
adiposity: "settings.sections.score.domain.adiposity",
|
||||
wellbeing: "settings.sections.score.domain.wellbeing",
|
||||
};
|
||||
@@ -154,7 +154,7 @@ describe("grantAllows", () => {
|
||||
*/
|
||||
describe("inviteGrant", () => {
|
||||
function captureDb(): {
|
||||
db: Pick<Prisma.TransactionClient, "accountGrant">;
|
||||
db: Pick<Prisma.TransactionClient, "accountGrant" | "auditLog">;
|
||||
writes: Array<{
|
||||
access: string;
|
||||
grantorId: string;
|
||||
@@ -170,6 +170,9 @@ describe("inviteGrant", () => {
|
||||
}> = [];
|
||||
const db = {
|
||||
accountGrant: {
|
||||
// No expired live-pair row in these fixtures, so the pre-create sweep
|
||||
// closes nothing and the audit branch is never reached.
|
||||
updateMany: async () => ({ count: 0 }),
|
||||
create: async (args: {
|
||||
data: {
|
||||
access: string;
|
||||
@@ -182,7 +185,7 @@ describe("inviteGrant", () => {
|
||||
return { id: "grant-1", ...args.data } as unknown as AccountGrant;
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<Prisma.TransactionClient, "accountGrant">;
|
||||
} as unknown as Pick<Prisma.TransactionClient, "accountGrant" | "auditLog">;
|
||||
return { db, writes };
|
||||
}
|
||||
|
||||
@@ -391,18 +394,19 @@ describe("grantCoversDomain", () => {
|
||||
|
||||
describe("inviteGrant, on scope", () => {
|
||||
function captureScopeDb(): {
|
||||
db: Pick<Prisma.TransactionClient, "accountGrant">;
|
||||
db: Pick<Prisma.TransactionClient, "accountGrant" | "auditLog">;
|
||||
writes: Array<{ scopeJson: unknown }>;
|
||||
} {
|
||||
const writes: Array<{ scopeJson: unknown }> = [];
|
||||
const db = {
|
||||
accountGrant: {
|
||||
updateMany: async () => ({ count: 0 }),
|
||||
create: async (args: { data: { scopeJson: unknown } }) => {
|
||||
writes.push(args.data);
|
||||
return { id: "grant-1", ...args.data } as unknown as AccountGrant;
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<Prisma.TransactionClient, "accountGrant">;
|
||||
} as unknown as Pick<Prisma.TransactionClient, "accountGrant" | "auditLog">;
|
||||
return { db, writes };
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ export class GrantError extends Error {
|
||||
* The narrow client slice these functions need, so a caller can pass a
|
||||
* transaction handle. Same shape as `issue-token.ts`.
|
||||
*/
|
||||
type GrantDb = Pick<Prisma.TransactionClient, "accountGrant">;
|
||||
type GrantDb = Pick<Prisma.TransactionClient, "accountGrant" | "auditLog">;
|
||||
|
||||
// ── The state machine, pure ─────────────────────────────────────────────────
|
||||
|
||||
@@ -379,6 +379,42 @@ export async function inviteGrant(
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// An expired grant confers nothing (see `grantState`), but its row still
|
||||
// sits in the live-pair slot the partial unique index guards: the index
|
||||
// predicate is `WHERE revoked_at IS NULL` and cannot read the clock. Without
|
||||
// ending the lapsed row here, a re-invitation for the same pair after expiry
|
||||
// always collides on P2002 and 409s, though the person genuinely has no
|
||||
// access anymore. Close it now — conditionally, the expiry state in the
|
||||
// WHERE — so "revocation never deletes" still holds and the fresh invitation
|
||||
// can mint its row. A live (unexpired) row is not matched, so its P2002 stays
|
||||
// correct. `revokedBy` is stamped GRANTOR rather than left null because the
|
||||
// paired CHECK (`account_grants_revoked_by_check`) requires revoked_at and
|
||||
// revoked_by to be both set or both null and the revoker enum has no expiry
|
||||
// member; the owner re-inviting is the actor that ends the lapsed row.
|
||||
const closed = await db.accountGrant.updateMany({
|
||||
where: {
|
||||
grantorId: input.grantorId,
|
||||
granteeId: input.granteeId,
|
||||
revokedAt: null,
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: { revokedAt: now, revokedBy: "GRANTOR" },
|
||||
});
|
||||
if (closed.count > 0) {
|
||||
// Keep "who had access, from when to when, and who ended it" answering
|
||||
// truthfully: the lapsed row ended by its own expiry, closed on re-invite.
|
||||
await auditLog("sharing.grant.expired_closed", {
|
||||
userId: input.grantorId,
|
||||
details: {
|
||||
granteeId: input.granteeId,
|
||||
closedCount: closed.count,
|
||||
reason: "expired",
|
||||
},
|
||||
client: db,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
return await db.accountGrant.create({
|
||||
data: {
|
||||
|
||||
@@ -439,6 +439,70 @@ describe("account grant lifecycle", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("re-invites after a grant EXPIRED, closing the lapsed row instead of colliding", async () => {
|
||||
// The bug carrier is the partial unique index `WHERE revoked_at IS NULL`:
|
||||
// it cannot read the clock, so an expired-but-never-revoked row keeps
|
||||
// occupying the live-pair slot and every re-invitation 409s. A mocked
|
||||
// Prisma cannot reproduce this — the index is the thing under test — so it
|
||||
// lives here, against real Postgres.
|
||||
for (const accepted of [true, false]) {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
|
||||
const first = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
});
|
||||
if (accepted) {
|
||||
// Acceptance is refused once lapsed, so stamp it directly to build the
|
||||
// "accepted then expired" row the report describes.
|
||||
await getPrismaClient().accountGrant.update({
|
||||
where: { id: first.id },
|
||||
data: { acceptedAt: new Date(Date.now() - 2000) },
|
||||
});
|
||||
}
|
||||
|
||||
// The lapsed row confers nothing already.
|
||||
expect(
|
||||
await findActiveGrant({ grantorId: owner.id, granteeId: delegate.id }),
|
||||
).toBeNull();
|
||||
|
||||
// Re-inviting the same pair must succeed rather than 409 on the index.
|
||||
const second = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.revokedAt).toBeNull();
|
||||
|
||||
// History kept, not deleted: the old row is now closed by expiry, and its
|
||||
// revoker is stamped so the paired CHECK holds.
|
||||
const old = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
||||
where: { id: first.id },
|
||||
});
|
||||
expect(old.revokedAt).not.toBeNull();
|
||||
expect(old.revokedBy).toBe("GRANTOR");
|
||||
|
||||
// Two rows for the pair: the ended one and the fresh one.
|
||||
expect(
|
||||
await getPrismaClient().accountGrant.count({
|
||||
where: { grantorId: owner.id, granteeId: delegate.id },
|
||||
}),
|
||||
).toBe(2);
|
||||
|
||||
// And the close is on the audit trail.
|
||||
const trail = await getPrismaClient().auditLog.findMany({
|
||||
where: { action: "sharing.grant.expired_closed", userId: owner.id },
|
||||
});
|
||||
expect(trail).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps history without limit — a third grant does not collide with two ended ones", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
|
||||
Reference in new issue
Block a user