feat(sharing): the shared-access panel and the record-activity view

Settings gains one section for one concept: who else can open this health
record, whose this account may open, and when they were opened. Split across
two pages it would be two half-answers.

An invitation confers nothing until the other person accepts it, and the card
says so above the field rather than after the submit — that is the part
somebody needs to know before they type a name. Refusals are named from the
stable errorCode, because "no account with that name" and "that person already
has access" are different situations with different next steps.

Ended grants stay in the list. The row is the consent record, and "who had
access, from when to when, and who ended it" is the question the panel exists
to answer; a list that dropped them could not. Revoking asks once and then does
it: reducing access must never be harder than granting it was.

`GET /api/account/activity` answers the owner's other question. One row per
person per day with a count, not one per request — a hundred identical lines
is a way of answering nothing. The window is published with the rows rather
than assumed: audit history is purged on a schedule the operator sets, so the
view prints the number the server resolved from the same setting the purge job
reads. An instance running ninety days says ninety.

Six locales ship with it.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 14:26:39 +02:00
1 parent dd3d5646de
commit 5199de78af
20 files changed
+1620

No files matched your search

+98
View File
@@ -4955,6 +4955,10 @@
"dismiss": "Verstanden",
"dismissError": "Konnte nicht gespeichert werden. Bitte versuche es erneut."
}
},
"access": {
"title": "Geteilter Zugriff",
"subtitle": "Wer deine Gesundheitsakte öffnen kann und wessen Akte du öffnen kannst."
}
},
"profile": "Profil",
@@ -9373,5 +9377,99 @@
"scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.",
"scopeCount": "{count} Einträge aus {groups} Bereichen",
"scopeSensitive": "inkl. {names}"
},
"recordSharing": {
"banner": {
"viewing": "Du bist in der Akte von {name}",
"readOnly": "Du kannst sie lesen, nicht ändern.",
"leave": "Zurück zu meiner Akte"
},
"switcher": {
"label": "Akte öffnen",
"ownRecord": "Meine eigene Akte",
"readOnly": "Nur lesen",
"canWrite": "Darf Einträge anlegen"
},
"switch": {
"failed": "Die Akte konnte nicht geöffnet werden. Bitte noch einmal versuchen.",
"noLongerAvailable": "Diese Akte ist nicht mehr für dich freigegeben."
},
"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.",
"leave": "Zurück zu meiner Akte"
},
"state": {
"active": "Aktiv",
"pending": "Wartet auf Zustimmung",
"expired": "Abgelaufen",
"revoked": "Beendet"
},
"row": {
"readOnly": "Darf die Akte lesen",
"canWrite": "Darf lesen und Einträge anlegen",
"invited": "Eingeladen",
"accepted": "Angenommen",
"expires": "Läuft ab",
"lastUsed": "Zuletzt geöffnet",
"neverUsed": "nie",
"endedByYou": "Von dir beendet",
"endedByOwner": "Zurückgezogen",
"handedBack": "Zurückgegeben",
"ended": "Beendet"
},
"invite": {
"title": "Jemanden einladen",
"description": "Vor der Zustimmung wird nichts geteilt. Bis dahin liegt die Einladung in den Einstellungen der anderen Person und gibt ihr nichts.",
"identifierLabel": "Benutzername oder E-Mail",
"identifierPlaceholder": "Der Name, mit dem sie sich anmeldet",
"identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.",
"submit": "Einladung senden",
"submitting": "Wird gesendet…",
"sent": "Einladung an {name} gesendet.",
"errorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen.",
"errorDuplicate": "Dieses Konto hat bereits Zugriff auf deine Akte.",
"errorSelf": "Das ist dein eigenes Konto.",
"errorInvalid": "Bitte einen Benutzernamen oder eine E-Mail-Adresse eingeben.",
"errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.",
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen."
},
"given": {
"title": "Wer meine Akte öffnen kann",
"description": "Beendeter Zugriff bleibt in der Liste, damit du immer siehst, wer ihn hatte und wann.",
"emptyTitle": "Niemand sonst kann deine Akte öffnen",
"emptyDescription": "Lade oben jemanden ein, wenn diese Person sie lesen können soll.",
"loadError": "Die Liste konnte nicht geladen werden.",
"revoke": "Zugriff beenden",
"withdrawInvite": "Zurückziehen",
"revokeTitle": "Zugriff von {name} beenden?",
"revokeBody": "Der Zugriff endet sofort, auch in jedem Browser, in dem deine Akte gerade offen ist. Was bereits gesehen wurde, lässt sich nicht zurückholen. Du kannst jederzeit erneut einladen.",
"revokeConfirm": "Zugriff beenden"
},
"received": {
"title": "Akten, die ich öffnen kann",
"description": "Eine Einladung gibt dir nichts, bevor du sie annimmst. Du kannst den Zugriff jederzeit zurückgeben.",
"emptyTitle": "Niemand hat eine Akte mit dir geteilt",
"emptyDescription": "Wenn dich jemand einlädt, erscheint die Einladung hier.",
"loadError": "Die Liste konnte nicht geladen werden.",
"accept": "Annehmen",
"accepting": "Wird angenommen…",
"open": "Öffnen",
"renounce": "Zurückgeben",
"renounceTitle": "Akte von {name} zurückgeben?",
"renounceBody": "Dein Zugriff endet sofort. Die Person kann dich jederzeit erneut einladen.",
"renounceConfirm": "Zurückgeben"
},
"activity": {
"title": "Wann meine Akte geöffnet wurde",
"description": "Jedes Mal, wenn jemand anderes deine Akte geöffnet hat.",
"retention": "Das umfasst die letzten {days} Tage. Ältere Einträge werden gelöscht.",
"emptyTitle": "Niemand hat deine Akte geöffnet",
"emptyDescription": "Zugriffe von anderen erscheinen hier, eine Zeile pro Person und Tag.",
"loadError": "Die Aktivität konnte nicht geladen werden.",
"opened": "{name} hat deine Akte {count}× geöffnet",
"acted": "{name} hat etwas in deiner Akte geändert",
"deletedAccount": "Ein gelöschtes Konto"
}
}
}
+98
View File
@@ -4955,6 +4955,10 @@
"dismiss": "Got it",
"dismissError": "Couldn't save that. Please try again."
}
},
"access": {
"title": "Shared access",
"subtitle": "Who can open your health record, and whose you can open."
}
},
"profile": "Profile",
@@ -9373,5 +9377,99 @@
"scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.",
"scopeCount": "{count} entries from {groups} areas",
"scopeSensitive": "incl. {names}"
},
"recordSharing": {
"banner": {
"viewing": "You are in {name}'s record",
"readOnly": "You can read it, not change it.",
"leave": "Back to my record"
},
"switcher": {
"label": "Open a record",
"ownRecord": "My own record",
"readOnly": "Read only",
"canWrite": "Can add entries"
},
"switch": {
"failed": "Could not open that record. Please try again.",
"noLongerAvailable": "That record is no longer shared with you."
},
"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.",
"leave": "Back to my record"
},
"state": {
"active": "Active",
"pending": "Waiting for acceptance",
"expired": "Lapsed",
"revoked": "Ended"
},
"row": {
"readOnly": "Can read the record",
"canWrite": "Can read and add entries",
"invited": "Invited",
"accepted": "Accepted",
"expires": "Lapses",
"lastUsed": "Last opened",
"neverUsed": "never",
"endedByYou": "You ended it",
"endedByOwner": "Withdrawn",
"handedBack": "Handed back",
"ended": "Ended"
},
"invite": {
"title": "Invite someone",
"description": "Nothing is shared until they accept. Until then the invitation sits in their settings and gives them nothing.",
"identifierLabel": "Username or e-mail",
"identifierPlaceholder": "The name they sign in with",
"identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.",
"submit": "Send invitation",
"submitting": "Sending…",
"sent": "Invitation sent to {name}.",
"errorNoAccount": "No account on this instance carries that name.",
"errorDuplicate": "That account already has access to your record.",
"errorSelf": "That is your own account.",
"errorInvalid": "Please enter a username or e-mail address.",
"errorRateLimited": "Too many invitations. Please try again later.",
"errorFailed": "The invitation could not be sent. Please try again."
},
"given": {
"title": "Who can open my record",
"description": "Ended access stays listed, so you can always see who had it and when.",
"emptyTitle": "Nobody else can open your record",
"emptyDescription": "Invite someone above if you want them to be able to read it.",
"loadError": "The list could not be loaded.",
"revoke": "End access",
"withdrawInvite": "Withdraw",
"revokeTitle": "End {name}'s access?",
"revokeBody": "They lose access immediately, including any browser they have your record open in. What they have already seen cannot be taken back. You can invite them again at any time.",
"revokeConfirm": "End access"
},
"received": {
"title": "Records I can open",
"description": "An invitation gives you nothing until you accept it. You can hand access back whenever you want.",
"emptyTitle": "Nobody has shared a record with you",
"emptyDescription": "When someone invites you, the invitation appears here.",
"loadError": "The list could not be loaded.",
"accept": "Accept",
"accepting": "Accepting…",
"open": "Open",
"renounce": "Hand back",
"renounceTitle": "Hand {name}'s record back?",
"renounceBody": "You lose access immediately. They can invite you again at any time.",
"renounceConfirm": "Hand back"
},
"activity": {
"title": "When my record was opened",
"description": "Every time someone else opened your record.",
"retention": "This covers the last {days} days. Older entries are deleted.",
"emptyTitle": "Nobody has opened your record",
"emptyDescription": "Openings by anyone other than you appear here, one line per person per day.",
"loadError": "The activity could not be loaded.",
"opened": "{name} opened your record {count}×",
"acted": "{name} made a change in your record",
"deletedAccount": "A deleted account"
}
}
}
+98
View File
@@ -4955,6 +4955,10 @@
"dismiss": "Entendido",
"dismissError": "No se pudo guardar. Inténtalo de nuevo."
}
},
"access": {
"title": "Acceso compartido",
"subtitle": "Quién puede abrir tu historial de salud y cuál puedes abrir tú."
}
},
"profile": "Perfil",
@@ -9373,5 +9377,99 @@
"scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.",
"scopeCount": "{count} entradas de {groups} áreas",
"scopeSensitive": "incl. {names}"
},
"recordSharing": {
"banner": {
"viewing": "Estás en el historial de {name}",
"readOnly": "Puedes leerlo, no modificarlo.",
"leave": "Volver a mi historial"
},
"switcher": {
"label": "Abrir un historial",
"ownRecord": "Mi propio historial",
"readOnly": "Solo lectura",
"canWrite": "Puede añadir entradas"
},
"switch": {
"failed": "No se pudo abrir ese historial. Inténtalo de nuevo.",
"noLongerAvailable": "Ese historial ya no está compartido contigo."
},
"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.",
"leave": "Volver a mi historial"
},
"state": {
"active": "Activo",
"pending": "Pendiente de aceptación",
"expired": "Caducado",
"revoked": "Finalizado"
},
"row": {
"readOnly": "Puede leer el historial",
"canWrite": "Puede leer y añadir entradas",
"invited": "Invitado",
"accepted": "Aceptado",
"expires": "Caduca",
"lastUsed": "Última apertura",
"neverUsed": "nunca",
"endedByYou": "Lo finalizaste tú",
"endedByOwner": "Retirado",
"handedBack": "Devuelto",
"ended": "Finalizado"
},
"invite": {
"title": "Invitar a alguien",
"description": "No se comparte nada hasta que acepte. Hasta entonces la invitación está en sus ajustes y no le da nada.",
"identifierLabel": "Usuario o correo electrónico",
"identifierPlaceholder": "El nombre con el que inicia sesión",
"identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.",
"submit": "Enviar invitación",
"submitting": "Enviando…",
"sent": "Invitación enviada a {name}.",
"errorNoAccount": "No hay ninguna cuenta con ese nombre en esta instancia.",
"errorDuplicate": "Esa cuenta ya tiene acceso a tu historial.",
"errorSelf": "Esa es tu propia cuenta.",
"errorInvalid": "Introduce un usuario o una dirección de correo.",
"errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.",
"errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo."
},
"given": {
"title": "Quién puede abrir mi historial",
"description": "El acceso finalizado sigue en la lista, para que siempre veas quién lo tuvo y cuándo.",
"emptyTitle": "Nadie más puede abrir tu historial",
"emptyDescription": "Invita a alguien arriba si quieres que pueda leerlo.",
"loadError": "No se pudo cargar la lista.",
"revoke": "Finalizar acceso",
"withdrawInvite": "Retirar",
"revokeTitle": "¿Finalizar el acceso de {name}?",
"revokeBody": "Pierde el acceso de inmediato, incluido cualquier navegador donde tenga tu historial abierto. Lo que ya ha visto no se puede recuperar. Puedes volver a invitarle cuando quieras.",
"revokeConfirm": "Finalizar acceso"
},
"received": {
"title": "Historiales que puedo abrir",
"description": "Una invitación no te da nada hasta que la aceptas. Puedes devolver el acceso cuando quieras.",
"emptyTitle": "Nadie ha compartido un historial contigo",
"emptyDescription": "Cuando alguien te invite, la invitación aparecerá aquí.",
"loadError": "No se pudo cargar la lista.",
"accept": "Aceptar",
"accepting": "Aceptando…",
"open": "Abrir",
"renounce": "Devolver",
"renounceTitle": "¿Devolver el historial de {name}?",
"renounceBody": "Pierdes el acceso de inmediato. Esa persona puede volver a invitarte cuando quiera.",
"renounceConfirm": "Devolver"
},
"activity": {
"title": "Cuándo se abrió mi historial",
"description": "Cada vez que otra persona abrió tu historial.",
"retention": "Esto abarca los últimos {days} días. Las entradas más antiguas se eliminan.",
"emptyTitle": "Nadie ha abierto tu historial",
"emptyDescription": "Las aperturas de otras personas aparecen aquí, una línea por persona y día.",
"loadError": "No se pudo cargar la actividad.",
"opened": "{name} abrió tu historial {count}×",
"acted": "{name} hizo un cambio en tu historial",
"deletedAccount": "Una cuenta eliminada"
}
}
}
+98
View File
@@ -4955,6 +4955,10 @@
"dismiss": "Compris",
"dismissError": "Enregistrement impossible. Veuillez réessayer."
}
},
"access": {
"title": "Accès partagé",
"subtitle": "Qui peut ouvrir votre dossier de santé, et lequel vous pouvez ouvrir."
}
},
"profile": "Profil",
@@ -9373,5 +9377,99 @@
"scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.",
"scopeCount": "{count} éléments issus de {groups} domaines",
"scopeSensitive": "dont {names}"
},
"recordSharing": {
"banner": {
"viewing": "Vous êtes dans le dossier de {name}",
"readOnly": "Vous pouvez le lire, pas le modifier.",
"leave": "Revenir à mon dossier"
},
"switcher": {
"label": "Ouvrir un dossier",
"ownRecord": "Mon propre dossier",
"readOnly": "Lecture seule",
"canWrite": "Peut ajouter des entrées"
},
"switch": {
"failed": "Impossible d'ouvrir ce dossier. Veuillez réessayer.",
"noLongerAvailable": "Ce dossier n'est plus partagé avec vous."
},
"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.",
"leave": "Revenir à mon dossier"
},
"state": {
"active": "Actif",
"pending": "En attente d'acceptation",
"expired": "Expiré",
"revoked": "Terminé"
},
"row": {
"readOnly": "Peut lire le dossier",
"canWrite": "Peut lire et ajouter des entrées",
"invited": "Invité le",
"accepted": "Accepté le",
"expires": "Expire le",
"lastUsed": "Dernière ouverture",
"neverUsed": "jamais",
"endedByYou": "Vous y avez mis fin",
"endedByOwner": "Retiré",
"handedBack": "Rendu",
"ended": "Terminé"
},
"invite": {
"title": "Inviter quelqu'un",
"description": "Rien n'est partagé avant son acceptation. D'ici là, l'invitation reste dans ses réglages et ne lui donne rien.",
"identifierLabel": "Nom d'utilisateur ou e-mail",
"identifierPlaceholder": "Le nom avec lequel elle se connecte",
"identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.",
"submit": "Envoyer l'invitation",
"submitting": "Envoi…",
"sent": "Invitation envoyée à {name}.",
"errorNoAccount": "Aucun compte de cette instance ne porte ce nom.",
"errorDuplicate": "Ce compte a déjà accès à votre dossier.",
"errorSelf": "C'est votre propre compte.",
"errorInvalid": "Saisissez un nom d'utilisateur ou une adresse e-mail.",
"errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.",
"errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer."
},
"given": {
"title": "Qui peut ouvrir mon dossier",
"description": "Les accès terminés restent dans la liste, pour que vous voyiez toujours qui l'a eu et quand.",
"emptyTitle": "Personne d'autre ne peut ouvrir votre dossier",
"emptyDescription": "Invitez quelqu'un ci-dessus si vous voulez qu'il puisse le lire.",
"loadError": "La liste n'a pas pu être chargée.",
"revoke": "Mettre fin à l'accès",
"withdrawInvite": "Retirer",
"revokeTitle": "Mettre fin à l'accès de {name} ?",
"revokeBody": "L'accès cesse immédiatement, y compris dans tout navigateur où votre dossier est ouvert. Ce qui a déjà été vu ne peut pas être repris. Vous pouvez inviter de nouveau à tout moment.",
"revokeConfirm": "Mettre fin à l'accès"
},
"received": {
"title": "Dossiers que je peux ouvrir",
"description": "Une invitation ne vous donne rien avant que vous l'acceptiez. Vous pouvez rendre l'accès quand vous voulez.",
"emptyTitle": "Personne n'a partagé de dossier avec vous",
"emptyDescription": "Quand quelqu'un vous invite, l'invitation apparaît ici.",
"loadError": "La liste n'a pas pu être chargée.",
"accept": "Accepter",
"accepting": "Acceptation…",
"open": "Ouvrir",
"renounce": "Rendre",
"renounceTitle": "Rendre le dossier de {name} ?",
"renounceBody": "Vous perdez l'accès immédiatement. Cette personne peut vous inviter de nouveau à tout moment.",
"renounceConfirm": "Rendre"
},
"activity": {
"title": "Quand mon dossier a été ouvert",
"description": "Chaque fois que quelqu'un d'autre a ouvert votre dossier.",
"retention": "Cela couvre les {days} derniers jours. Les entrées plus anciennes sont supprimées.",
"emptyTitle": "Personne n'a ouvert votre dossier",
"emptyDescription": "Les ouvertures par d'autres apparaissent ici, une ligne par personne et par jour.",
"loadError": "L'activité n'a pas pu être chargée.",
"opened": "{name} a ouvert votre dossier {count}×",
"acted": "{name} a fait une modification dans votre dossier",
"deletedAccount": "Un compte supprimé"
}
}
}
+98
View File
@@ -4955,6 +4955,10 @@
"dismiss": "Ho capito",
"dismissError": "Non è stato possibile salvare. Riprova."
}
},
"access": {
"title": "Accesso condiviso",
"subtitle": "Chi può aprire la tua cartella sanitaria e quale puoi aprire tu."
}
},
"profile": "Profilo",
@@ -9373,5 +9377,99 @@
"scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.",
"scopeCount": "{count} voci da {groups} aree",
"scopeSensitive": "incl. {names}"
},
"recordSharing": {
"banner": {
"viewing": "Sei nella cartella di {name}",
"readOnly": "Puoi leggerla, non modificarla.",
"leave": "Torna alla mia cartella"
},
"switcher": {
"label": "Apri una cartella",
"ownRecord": "La mia cartella",
"readOnly": "Sola lettura",
"canWrite": "Può aggiungere voci"
},
"switch": {
"failed": "Non è stato possibile aprire quella cartella. Riprova.",
"noLongerAvailable": "Quella cartella non è più condivisa con te."
},
"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.",
"leave": "Torna alla mia cartella"
},
"state": {
"active": "Attivo",
"pending": "In attesa di accettazione",
"expired": "Scaduto",
"revoked": "Terminato"
},
"row": {
"readOnly": "Può leggere la cartella",
"canWrite": "Può leggere e aggiungere voci",
"invited": "Invitato il",
"accepted": "Accettato il",
"expires": "Scade il",
"lastUsed": "Ultima apertura",
"neverUsed": "mai",
"endedByYou": "L'hai terminato tu",
"endedByOwner": "Revocato",
"handedBack": "Restituito",
"ended": "Terminato"
},
"invite": {
"title": "Invita qualcuno",
"description": "Nulla viene condiviso finché non accetta. Fino ad allora l'invito resta nelle sue impostazioni e non le dà nulla.",
"identifierLabel": "Nome utente o e-mail",
"identifierPlaceholder": "Il nome con cui accede",
"identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.",
"submit": "Invia invito",
"submitting": "Invio…",
"sent": "Invito inviato a {name}.",
"errorNoAccount": "Nessun account su questa istanza ha quel nome.",
"errorDuplicate": "Quell'account ha già accesso alla tua cartella.",
"errorSelf": "Questo è il tuo account.",
"errorInvalid": "Inserisci un nome utente o un indirizzo e-mail.",
"errorRateLimited": "Troppi inviti. Riprova più tardi.",
"errorFailed": "Non è stato possibile inviare l'invito. Riprova."
},
"given": {
"title": "Chi può aprire la mia cartella",
"description": "Gli accessi terminati restano in elenco, così vedi sempre chi li ha avuti e quando.",
"emptyTitle": "Nessun altro può aprire la tua cartella",
"emptyDescription": "Invita qualcuno qui sopra se vuoi che possa leggerla.",
"loadError": "Non è stato possibile caricare l'elenco.",
"revoke": "Termina accesso",
"withdrawInvite": "Ritira",
"revokeTitle": "Terminare l'accesso di {name}?",
"revokeBody": "L'accesso finisce subito, anche in qualsiasi browser in cui la tua cartella è aperta. Ciò che è già stato visto non può essere ritirato. Puoi invitare di nuovo quando vuoi.",
"revokeConfirm": "Termina accesso"
},
"received": {
"title": "Cartelle che posso aprire",
"description": "Un invito non ti dà nulla finché non lo accetti. Puoi restituire l'accesso quando vuoi.",
"emptyTitle": "Nessuno ha condiviso una cartella con te",
"emptyDescription": "Quando qualcuno ti invita, l'invito compare qui.",
"loadError": "Non è stato possibile caricare l'elenco.",
"accept": "Accetta",
"accepting": "Accettazione…",
"open": "Apri",
"renounce": "Restituisci",
"renounceTitle": "Restituire la cartella di {name}?",
"renounceBody": "Perdi l'accesso subito. Quella persona può invitarti di nuovo quando vuole.",
"renounceConfirm": "Restituisci"
},
"activity": {
"title": "Quando è stata aperta la mia cartella",
"description": "Ogni volta che qualcun altro ha aperto la tua cartella.",
"retention": "Copre gli ultimi {days} giorni. Le voci più vecchie vengono eliminate.",
"emptyTitle": "Nessuno ha aperto la tua cartella",
"emptyDescription": "Le aperture da parte di altri compaiono qui, una riga per persona e giorno.",
"loadError": "Non è stato possibile caricare l'attività.",
"opened": "{name} ha aperto la tua cartella {count}×",
"acted": "{name} ha fatto una modifica nella tua cartella",
"deletedAccount": "Un account eliminato"
}
}
}
+98
View File
@@ -4955,6 +4955,10 @@
"dismiss": "Rozumiem",
"dismissError": "Nie udało się zapisać. Spróbuj ponownie."
}
},
"access": {
"title": "Udostępniony dostęp",
"subtitle": "Kto może otworzyć twoją kartę zdrowia i czyją kartę możesz otworzyć ty."
}
},
"profile": "Profil",
@@ -9373,5 +9377,99 @@
"scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.",
"scopeCount": "{count} pozycji z {groups} obszarów",
"scopeSensitive": "w tym {names}"
},
"recordSharing": {
"banner": {
"viewing": "Jesteś w karcie użytkownika {name}",
"readOnly": "Możesz ją czytać, nie zmieniać.",
"leave": "Wróć do mojej karty"
},
"switcher": {
"label": "Otwórz kartę",
"ownRecord": "Moja własna karta",
"readOnly": "Tylko odczyt",
"canWrite": "Może dodawać wpisy"
},
"switch": {
"failed": "Nie udało się otworzyć tej karty. Spróbuj ponownie.",
"noLongerAvailable": "Ta karta nie jest już dla ciebie udostępniona."
},
"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.",
"leave": "Wróć do mojej karty"
},
"state": {
"active": "Aktywny",
"pending": "Czeka na akceptację",
"expired": "Wygasł",
"revoked": "Zakończony"
},
"row": {
"readOnly": "Może czytać kartę",
"canWrite": "Może czytać i dodawać wpisy",
"invited": "Zaproszono",
"accepted": "Zaakceptowano",
"expires": "Wygasa",
"lastUsed": "Ostatnio otwarto",
"neverUsed": "nigdy",
"endedByYou": "Zakończone przez ciebie",
"endedByOwner": "Wycofane",
"handedBack": "Zwrócone",
"ended": "Zakończone"
},
"invite": {
"title": "Zaproś kogoś",
"description": "Nic nie jest udostępniane, dopóki ta osoba nie zaakceptuje. Do tego czasu zaproszenie leży w jej ustawieniach i nic jej nie daje.",
"identifierLabel": "Nazwa użytkownika lub e-mail",
"identifierPlaceholder": "Nazwa, którą się loguje",
"identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.",
"submit": "Wyślij zaproszenie",
"submitting": "Wysyłanie…",
"sent": "Zaproszenie wysłane do {name}.",
"errorNoAccount": "Na tej instancji nie ma konta o takiej nazwie.",
"errorDuplicate": "To konto ma już dostęp do twojej karty.",
"errorSelf": "To twoje własne konto.",
"errorInvalid": "Podaj nazwę użytkownika lub adres e-mail.",
"errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.",
"errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie."
},
"given": {
"title": "Kto może otworzyć moją kartę",
"description": "Zakończony dostęp zostaje na liście, żebyś zawsze widział, kto go miał i kiedy.",
"emptyTitle": "Nikt inny nie może otworzyć twojej karty",
"emptyDescription": "Zaproś kogoś powyżej, jeśli chcesz, żeby mógł ją czytać.",
"loadError": "Nie udało się wczytać listy.",
"revoke": "Zakończ dostęp",
"withdrawInvite": "Wycofaj",
"revokeTitle": "Zakończyć dostęp dla {name}?",
"revokeBody": "Dostęp kończy się natychmiast, także w każdej przeglądarce, w której twoja karta jest otwarta. Tego, co już zostało zobaczone, nie da się cofnąć. Możesz zaprosić ponownie w dowolnej chwili.",
"revokeConfirm": "Zakończ dostęp"
},
"received": {
"title": "Karty, które mogę otworzyć",
"description": "Zaproszenie nic ci nie daje, dopóki go nie zaakceptujesz. Dostęp możesz zwrócić w dowolnej chwili.",
"emptyTitle": "Nikt nie udostępnił ci karty",
"emptyDescription": "Kiedy ktoś cię zaprosi, zaproszenie pojawi się tutaj.",
"loadError": "Nie udało się wczytać listy.",
"accept": "Akceptuj",
"accepting": "Akceptowanie…",
"open": "Otwórz",
"renounce": "Zwróć",
"renounceTitle": "Zwrócić kartę użytkownika {name}?",
"renounceBody": "Tracisz dostęp natychmiast. Ta osoba może zaprosić cię ponownie w dowolnej chwili.",
"renounceConfirm": "Zwróć"
},
"activity": {
"title": "Kiedy otwierano moją kartę",
"description": "Za każdym razem, gdy ktoś inny otworzył twoją kartę.",
"retention": "Obejmuje ostatnie {days} dni. Starsze wpisy są usuwane.",
"emptyTitle": "Nikt nie otworzył twojej karty",
"emptyDescription": "Otwarcia przez inne osoby pojawiają się tutaj, jedna linia na osobę i dzień.",
"loadError": "Nie udało się wczytać aktywności.",
"opened": "{name} otworzył twoją kartę {count}×",
"acted": "{name} wprowadził zmianę w twojej karcie",
"deletedAccount": "Usunięte konto"
}
}
}
@@ -498,6 +498,40 @@ const REGISTRY: DestructiveEntry[] = [
],
},
// ── Account sharing ─────────────────────────────────────────────────────
{
file: "components/settings/access/grants-given-card.tsx",
destroys:
"another account's standing access to this health record, and every browser session of theirs that was inside it",
recovery: "permanent",
confirm: ["ConfirmButton"],
undetected:
"No detector sees it. The DELETE goes out from `useRevokeGrant` in `lib/queries/use-account-grants.ts`, and `lib/` is an excluded root; the card itself only calls `.mutate(grant.id)` with a bare string, which no payload pattern matches, and the route path lives in the hook rather than here. Registered by hand.",
},
{
file: "components/settings/access/grants-received-card.tsx",
destroys:
"this account's own access to somebody else's record, handed back rather than withdrawn",
recovery: "permanent",
confirm: ["ConfirmButton"],
undetected:
"Same shape as the card above, and one step further out of reach: renouncing is a POST, so not even a DELETE goes out. The path (`/api/account/grants/{id}/renounce`) names an act the D2 vocabulary does not carry, and it lives in the hook regardless. Registered by hand.",
},
{
file: "components/settings/access/grant-invite-card.tsx",
destroys:
"nothing — it offers access, and the offer confers nothing until the other person accepts it",
recovery: "permanent",
confirm: [],
unconfirmed: [
{
control: "the invitation form",
reason:
"D3 flags this file for `expiresAt: null` in the invite payload, reading it as a field being blanked. It is not: null means the grant runs until somebody ends it, which is the common household case and the absence of an expiry rather than the removal of one. The form creates a pending row and destroys nothing, and gating a creation behind a confirmation dialog would teach people to click through the dialogs that do guard a loss.",
},
],
},
// ── Admin ───────────────────────────────────────────────────────────────
{
file: "components/admin/danger-zone-section.tsx",
+128
View File
@@ -0,0 +1,128 @@
/**
* `GET /api/account/activity` — who opened this account's record, and when.
*
* The owner's side of the audit actor. Every row filed under this account that
* somebody ELSE performed, newest first: the day-coalesced read rows the
* delegated resolver writes, and any delegated action that lands an audit row
* of its own. Rows the owner performed themselves are absent by construction —
* `actorUserId` is null for those, and this feed is not a second copy of the
* security-activity feed.
*
* **The window is published, not implied.** `retentionDays` rides the response
* because audit rows are purged on a schedule the operator configures
* (`AUDIT_LOG_RETENTION_DAYS`, 365 by default) and a view that showed "the
* activity" without saying how far back it goes would claim a completeness it
* does not have. The number is resolved server-side from the same function the
* purge job uses, so the sentence in the UI cannot drift from the job that
* makes it true — an instance running a 90-day window says 90.
*
* Not delegable: bare `requireAuth()` refuses under a switch. A delegate must
* not be able to read the owner's record of who has been in it — that list
* names other delegates, and a grant is not an introduction to the household.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { DELEGATED_ACCESS_ACTION } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { annotate } from "@/lib/logging/context";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
export const dynamic = "force-dynamic";
/**
* How many rows the feed returns. One row per (delegate, day) for reads, so a
* hundred covers months of a busy household — and a longer list answers a
* question nobody asked while making the recent activity harder to see.
*/
const MAX_ROWS = 100;
export interface RecordActivityEntry {
id: string;
/**
* Who did it. Null when that account has since been deleted: the id on the
* row is deliberately not a foreign key, so history is not rewritten when a
* person leaves. A row whose actor cannot be resolved says "a deleted
* account", which is true, rather than silently reading as the owner's own.
*/
actor: { id: string; username: string; displayName: string | null } | null;
action: string;
/**
* For a coalesced read row, how many times that delegate opened the record
* that day. Null for anything else — a single act has no count, and a `1`
* there would invite the reader to add up numbers that mean different things.
*/
accesses: number | null;
createdAt: string;
}
/** The `accesses` counter, when the row is a coalesced read. */
function readAccessCount(
action: string,
details: string | null,
): number | null {
if (action !== DELEGATED_ACCESS_ACTION || details === null) return null;
try {
const parsed: unknown = JSON.parse(details);
if (typeof parsed !== "object" || parsed === null) return null;
const value = (parsed as { accesses?: unknown }).accesses;
return typeof value === "number" && Number.isFinite(value) ? value : null;
} catch {
// A row whose details cannot be parsed still happened. Dropping the count
// loses a number; dropping the row would lose the access.
return null;
}
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const rows = await prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
},
orderBy: { createdAt: "desc" },
take: MAX_ROWS,
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
});
const actorIds = [
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
];
const actors = await prisma.user.findMany({
where: { id: { in: actorIds } },
select: GRANT_PARTY_SELECT,
});
const byId = new Map(actors.map((a) => [a.id, a]));
const entries: RecordActivityEntry[] = rows.map((row) => ({
id: row.id,
actor: row.actorUserId ? (byId.get(row.actorUserId) ?? null) : null,
action: row.action,
accesses: readAccessCount(row.action, row.details),
createdAt: row.createdAt.toISOString(),
}));
annotate({
action: { name: "sharing.activity.list" },
meta: { entry_count: entries.length },
});
return apiSuccess({
entries,
// The bound the view states out loud. Resolved here so the UI never has to
// hardcode a number the operator can change.
retentionDays: getAuditLogRetentionDays(),
});
});
+2
View File
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import type { JSX } from "react";
import { AccessSection } from "@/components/settings/access-section";
import { AccountSection } from "@/components/settings/account-section";
import { AnamnesisSection } from "@/components/settings/anamnesis-section";
import { SecuritySection } from "@/components/settings/security-section";
@@ -60,6 +61,7 @@ const SECTION_COMPONENTS: Record<
() => JSX.Element | null
> = {
account: AccountSection,
access: AccessSection,
security: SecuritySection,
modules: ModulesSection,
about: AboutSection,
@@ -105,6 +105,10 @@ describe("SETTINGS_SECTION_SLUGS", () => {
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"security",
// v1.36.0 — account sharing: who may open this record, and whose this
// account may open. Beside `security`, and distinct from the `sharing`
// slug further down (the clinician share LINK).
"access",
"modules",
"integrations",
"sources",
@@ -0,0 +1,43 @@
"use client";
import { GrantInviteCard } 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";
import { RecordActivityCard } from "@/components/settings/access/record-activity-card";
/**
* v1.36.0 — Settings → "Shared access".
*
* One section for one concept: who else can open this health record, and whose
* this account may open. Split across two pages it would be two half-answers —
* the settings IA rule this repository keeps rediscovering — so both directions
* and the activity that follows from them live in one column, in the order
* somebody reads them:
*
* 1. Invite — the act.
* 2. Who has access — the standing state, and the way to end it.
* 3. What I can open — the same, from the other side.
* 4. When it was opened — what actually happened.
*
* Distinct from Settings → "Health record" → sharing links, which mints a
* time-boxed read-only link to a REPORT for a clinician. This section is about
* another ACCOUNT on this instance being able to open the live record, which is
* a standing relationship rather than a document handed over once.
*
* Every card here reads and writes routes that refuse while the browser is
* acting on somebody else's record, and the section is unreachable in that
* state because the whole Settings entry drops out of the nav. Grant management
* is the one surface a delegate must have no reach into at all: a delegate who
* could invite, widen or transfer would be re-delegating access nobody granted
* them.
*/
export function AccessSection() {
return (
<div className="space-y-4">
<GrantInviteCard />
<GrantsGivenCard />
<GrantsReceivedCard />
<RecordActivityCard />
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { UserPlus } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useInviteGrant } from "@/lib/queries/use-account-grants";
/**
* v1.36.0 — offering somebody read access to this record.
*
* One field, because there is one decision: who. The access level is not
* offered — v1 grants read and only read, and a control whose only setting is
* its default is a promise about a future release rather than a choice about
* this one.
*
* The invitation confers nothing until the other person accepts it. The card
* says so above the field rather than after the submit, because that is the
* part somebody needs to know BEFORE they type a name: nothing has been shared
* yet, and nothing will be until the other side agrees.
*
* Errors are named, not generic. "No account with that name" and "that person
* already has access" are different situations with different next steps, and
* the server publishes a stable `meta.errorCode` for each precisely so this
* card does not have to read prose to tell them apart.
*/
export function GrantInviteCard() {
const { t } = useTranslations();
const invite = useInviteGrant();
const [identifier, setIdentifier] = useState("");
const [error, setError] = useState<string | null>(null);
const [invited, setInvited] = useState<string | null>(null);
const submit = (event: React.FormEvent) => {
event.preventDefault();
const value = identifier.trim();
if (value.length === 0) return;
setError(null);
setInvited(null);
invite.mutate(
{ identifier: value, expiresAt: null },
{
onSuccess: (grant) => {
setIdentifier("");
setInvited(grant.account.username);
},
onError: (err) => setError(t(inviteErrorKey(err))),
},
);
};
return (
<SettingsCard data-slot="grant-invite-card">
<SettingsCardHeader
icon={UserPlus}
title={t("recordSharing.invite.title")}
description={t("recordSharing.invite.description")}
/>
<form onSubmit={submit} className="mt-4 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="grant-invite-identifier">
{t("recordSharing.invite.identifierLabel")}
</Label>
<Input
id="grant-invite-identifier"
data-slot="grant-invite-identifier"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder={t("recordSharing.invite.identifierPlaceholder")}
autoComplete="off"
maxLength={255}
/>
<p className="text-muted-foreground text-xs">
{t("recordSharing.invite.identifierHint")}
</p>
</div>
<Button
type="submit"
data-slot="grant-invite-submit"
disabled={invite.isPending || identifier.trim().length === 0}
>
{invite.isPending
? t("recordSharing.invite.submitting")
: t("recordSharing.invite.submit")}
</Button>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
{invited && (
<p role="status" className="text-sm">
{t("recordSharing.invite.sent", { name: invited })}
</p>
)}
</form>
</SettingsCard>
);
}
/**
* The message for a refused invitation.
*
* Branches on the stable `meta.errorCode` rather than the status alone: a 422
* covers both a malformed identifier and an invitation somebody addressed to
* themselves, and telling those two apart is the difference between "fix the
* field" and "you already have your own record".
*/
function inviteErrorKey(err: unknown): string {
const code =
err instanceof ApiError && typeof err.meta?.errorCode === "string"
? err.meta.errorCode
: null;
switch (code) {
case "sharing.invite.self":
return "recordSharing.invite.errorSelf";
case "sharing.invite.duplicate":
return "recordSharing.invite.errorDuplicate";
case "sharing.invite.invalid":
return "recordSharing.invite.errorInvalid";
default:
break;
}
if (err instanceof ApiError && err.status === 404) {
return "recordSharing.invite.errorNoAccount";
}
if (err instanceof ApiError && err.status === 429) {
return "recordSharing.invite.errorRateLimited";
}
return "recordSharing.invite.errorFailed";
}
@@ -0,0 +1,147 @@
"use client";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { accountLabel } from "@/lib/sharing/account-access-view";
import type { GrantRow, GrantState } from "@/lib/queries/use-account-grants";
/**
* v1.36.0 — one grant, from either side.
*
* The row's whole job is to say what a person can see and since when, without
* anybody having to read a timestamp and work out the answer. Two rules shape
* it:
*
* * **The state comes from the server.** `state` is resolved by the same
* function the request resolver's predicate is built on. The row renders
* it; it never compares `expiresAt` to the clock, because a panel that
* said ACTIVE about a grant the server had stopped honouring would be a
* second opinion about somebody's access to a health record.
* * **Ended rows stay.** A revoked grant is not gone from the list — the row
* IS the consent record, and "who had access, from when to when, and who
* ended it" is the question the panel exists to answer. It renders muted
* with its end named, rather than disappearing as though it never was.
*
* The person's name is `text-foreground` (it is the content); every date,
* level and last-used note is `text-muted-foreground` (they are meta). The
* status badge carries meaning rather than decoration, so it uses the semantic
* tokens: active is `success`, an unanswered invitation is `warning`, an ended
* one is neutral.
*/
const STATE_VARIANT: Record<
GrantState,
"default" | "secondary" | "destructive" | "outline"
> = {
ACTIVE: "default",
PENDING: "secondary",
EXPIRED: "outline",
REVOKED: "outline",
};
export function GrantRowItem({
grant,
/** What ended it reads differently depending on who is looking. */
side,
actions,
}: {
grant: GrantRow;
side: "given" | "received";
actions?: ReactNode;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const ended = grant.state === "REVOKED" || grant.state === "EXPIRED";
return (
<li
data-slot="grant-row"
data-grant-id={grant.id}
data-grant-state={grant.state}
className="flex flex-wrap items-start justify-between gap-3 py-3"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground truncate text-sm font-medium">
{accountLabel(grant.account)}
</span>
<Badge variant={STATE_VARIANT[grant.state]}>
{t(`recordSharing.state.${stateKey(grant.state)}`)}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{grant.access === "WRITE"
? t("recordSharing.row.canWrite")
: t("recordSharing.row.readOnly")}
</p>
<dl className="text-muted-foreground space-y-0.5 text-xs">
<div className="flex flex-wrap gap-x-1">
<dt>{t("recordSharing.row.invited")}</dt>
<dd>{fmt.dateTime(new Date(grant.invitedAt))}</dd>
</div>
{grant.acceptedAt && (
<div className="flex flex-wrap gap-x-1">
<dt>{t("recordSharing.row.accepted")}</dt>
<dd>{fmt.dateTime(new Date(grant.acceptedAt))}</dd>
</div>
)}
{grant.expiresAt && !ended && (
<div className="flex flex-wrap gap-x-1">
<dt>{t("recordSharing.row.expires")}</dt>
<dd>{fmt.dateTime(new Date(grant.expiresAt))}</dd>
</div>
)}
{grant.state === "ACTIVE" && (
<div className="flex flex-wrap gap-x-1">
<dt>{t("recordSharing.row.lastUsed")}</dt>
{/* Absence reads as absence: a grant that has never been
exercised says so, rather than showing a blank. */}
<dd>
{grant.lastUsedAt
? fmt.dateTime(new Date(grant.lastUsedAt))
: t("recordSharing.row.neverUsed")}
</dd>
</div>
)}
{grant.revokedAt && (
<div className="flex flex-wrap gap-x-1">
<dt>{endedByKey(grant.revokedBy, side, t)}</dt>
<dd>{fmt.dateTime(new Date(grant.revokedAt))}</dd>
</div>
)}
</dl>
</div>
{actions && <div className="flex shrink-0 items-center">{actions}</div>}
</li>
);
}
function stateKey(state: GrantState): string {
return state.toLowerCase();
}
/**
* "You withdrew it" versus "she handed it back" — the same column, read from
* two sides. `revokedBy` is what makes the distinction possible; conflating
* them would misreport an ordinary handover as a falling-out.
*/
function endedByKey(
revokedBy: GrantRow["revokedBy"],
side: "given" | "received",
t: (key: string) => string,
): string {
if (revokedBy === "GRANTOR") {
return side === "given"
? t("recordSharing.row.endedByYou")
: t("recordSharing.row.endedByOwner");
}
if (revokedBy === "GRANTEE") {
return side === "given"
? t("recordSharing.row.handedBack")
: t("recordSharing.row.endedByYou");
}
return t("recordSharing.row.ended");
}
@@ -0,0 +1,105 @@
"use client";
import { Loader2, Users } from "lucide-react";
import { GrantRowItem } from "@/components/settings/access/grant-row";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useTranslations } from "@/lib/i18n/context";
import { accountLabel } from "@/lib/sharing/account-access-view";
import {
useAccountGrants,
useRevokeGrant,
type GrantRow,
} from "@/lib/queries/use-account-grants";
/**
* v1.36.0 — who can open this record.
*
* The compliance surface: at any moment the owner can see who has access, at
* what level, since when, and whether it is still being used. Ended grants stay
* in the list because the row is the consent record.
*
* Revoking asks once and then does it. No step-up, no typed confirmation —
* reducing access must never be harder than granting it was, and the one party
* here whose intent needs no verification is the person closing their own
* health record to somebody. The confirm dialog exists only because the click
* target sits in a list and a mis-tap should not end an access silently; it
* names the person and the act.
*/
export function GrantsGivenCard() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useAccountGrants();
const revoke = useRevokeGrant();
const given = data?.given ?? [];
return (
<SettingsCard data-slot="grants-given-card">
<SettingsCardHeader
icon={Users}
title={t("recordSharing.given.title")}
description={t("recordSharing.given.description")}
/>
<div className="mt-4">
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
aria-label={t("common.loading")}
/>
)}
{isError && (
<QueryErrorCard
title={t("recordSharing.given.loadError")}
onRetry={() => void refetch()}
/>
)}
{!isLoading && !isError && given.length === 0 && (
<EmptyState
icon={<Users className="size-6" />}
title={t("recordSharing.given.emptyTitle")}
description={t("recordSharing.given.emptyDescription")}
/>
)}
{given.length > 0 && (
<ul data-slot="grants-given-list" className="divide-y">
{given.map((grant) => (
<GrantRowItem
key={grant.id}
grant={grant}
side="given"
actions={renderRevoke(grant)}
/>
))}
</ul>
)}
</div>
</SettingsCard>
);
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.
if (grant.state !== "ACTIVE" && grant.state !== "PENDING") return null;
const name = accountLabel(grant.account);
return (
<ConfirmButton
slot="grant-revoke"
size="sm"
label={
grant.state === "PENDING"
? t("recordSharing.given.withdrawInvite")
: t("recordSharing.given.revoke")
}
title={t("recordSharing.given.revokeTitle", { name })}
body={t("recordSharing.given.revokeBody", { name })}
confirmLabel={t("recordSharing.given.revokeConfirm")}
pending={revoke.isPending}
onConfirm={() => revoke.mutate(grant.id)}
/>
);
}
}
@@ -0,0 +1,132 @@
"use client";
import { FolderOpen, Loader2 } from "lucide-react";
import { GrantRowItem } from "@/components/settings/access/grant-row";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { useTranslations } from "@/lib/i18n/context";
import { accountLabel } from "@/lib/sharing/account-access-view";
import {
useAcceptGrant,
useAccountGrants,
useRenounceGrant,
type GrantRow,
} from "@/lib/queries/use-account-grants";
/**
* v1.36.0 — the records this account has been offered, and the ones it holds.
*
* The delegate's side. Three affordances, and the order they appear in is the
* order somebody meets them:
*
* * **Accept.** An invitation confers nothing until this button is pressed.
* That is the consent handshake, not a formality: being handed read access
* to another person's health record is not something to impose silently,
* and the acceptance is what the grant row records.
* * **Open.** Once accepted, the record can be opened from here as well as
* from the user menu — somebody who has just accepted is already looking
* 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".
*/
export function GrantsReceivedCard() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useAccountGrants();
const accept = useAcceptGrant();
const renounce = useRenounceGrant();
const switchAccount = useAccountSwitch();
const received = data?.received ?? [];
return (
<SettingsCard data-slot="grants-received-card">
<SettingsCardHeader
icon={FolderOpen}
title={t("recordSharing.received.title")}
description={t("recordSharing.received.description")}
/>
<div className="mt-4">
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
aria-label={t("common.loading")}
/>
)}
{isError && (
<QueryErrorCard
title={t("recordSharing.received.loadError")}
onRetry={() => void refetch()}
/>
)}
{!isLoading && !isError && received.length === 0 && (
<EmptyState
icon={<FolderOpen className="size-6" />}
title={t("recordSharing.received.emptyTitle")}
description={t("recordSharing.received.emptyDescription")}
/>
)}
{received.length > 0 && (
<ul data-slot="grants-received-list" className="divide-y">
{received.map((grant) => (
<GrantRowItem
key={grant.id}
grant={grant}
side="received"
actions={renderActions(grant)}
/>
))}
</ul>
)}
</div>
</SettingsCard>
);
function renderActions(grant: GrantRow) {
if (grant.state === "PENDING") {
return (
<Button
size="sm"
data-slot="grant-accept"
disabled={accept.isPending}
onClick={() => accept.mutate(grant.id)}
>
{accept.isPending
? t("recordSharing.received.accepting")
: t("recordSharing.received.accept")}
</Button>
);
}
if (grant.state !== "ACTIVE") return null;
const name = accountLabel(grant.account);
return (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
data-slot="grant-open"
data-account-id={grant.account.id}
disabled={switchAccount.isPending}
onClick={() => switchAccount.mutate(grant.account.id)}
>
{t("recordSharing.received.open")}
</Button>
<ConfirmButton
slot="grant-renounce"
size="sm"
label={t("recordSharing.received.renounce")}
title={t("recordSharing.received.renounceTitle", { name })}
body={t("recordSharing.received.renounceBody", { name })}
confirmLabel={t("recordSharing.received.renounceConfirm")}
pending={renounce.isPending}
onConfirm={() => renounce.mutate(grant.id)}
/>
</div>
);
}
}
@@ -0,0 +1,127 @@
"use client";
import { History, Loader2 } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { accountLabel } from "@/lib/sharing/account-access-view";
import {
useRecordActivity,
type RecordActivityEntry,
} from "@/lib/queries/use-account-grants";
/**
* v1.36.0 — when this record was opened, and by whom.
*
* ## The card says how far back it can see
*
* Access rows are purged on a retention schedule (365 days by default, and the
* operator can shorten it). A view titled "activity" with no window stated
* would imply a completeness it does not have — somebody checking whether
* their record was opened last spring would read an empty list as "no" when
* the honest answer is "that is further back than this can see". So the bound
* is printed under the heading, and it is the number the SERVER resolved from
* the same setting the purge job reads, never a constant compiled in here. An
* instance running a 90-day window says 90.
*
* ## What a row is
*
* One row per delegate per day, with a count — not one row per request. A
* hundred identical lines saying "she read your record" is a way of answering
* nothing; "she was in your record on Tuesday, 40 times" is the answer somebody
* actually wants. Rows the owner performed themselves never appear here: this
* is the record of what SOMEBODY ELSE did, and the account's own security
* activity has its own surface.
*/
export function RecordActivityCard() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useRecordActivity();
const entries = data?.entries ?? [];
return (
<SettingsCard data-slot="record-activity-card">
<SettingsCardHeader
icon={History}
title={t("recordSharing.activity.title")}
description={t("recordSharing.activity.description")}
/>
<div className="mt-4 space-y-3">
{/* The window, stated. Rendered only once the server has said what it
is — a placeholder number here would be exactly the invented value
the sentence exists to avoid. */}
{data && (
<p
data-slot="record-activity-retention"
className="text-muted-foreground text-xs"
>
{t("recordSharing.activity.retention", {
days: data.retentionDays,
})}
</p>
)}
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
aria-label={t("common.loading")}
/>
)}
{isError && (
<QueryErrorCard
title={t("recordSharing.activity.loadError")}
onRetry={() => void refetch()}
/>
)}
{!isLoading && !isError && entries.length === 0 && (
<EmptyState
icon={<History className="size-6" />}
title={t("recordSharing.activity.emptyTitle")}
description={t("recordSharing.activity.emptyDescription")}
/>
)}
{entries.length > 0 && (
<ul data-slot="record-activity-list" className="divide-y">
{entries.map((entry) => (
<ActivityRow key={entry.id} entry={entry} />
))}
</ul>
)}
</div>
</SettingsCard>
);
}
function ActivityRow({ entry }: { entry: RecordActivityEntry }) {
const { t } = useTranslations();
const fmt = useFormatters();
// A deleted account renders as one. The audit column is deliberately not a
// foreign key, so the id outlives the person; showing nothing would read as
// though the owner had done it themselves.
const who = entry.actor
? accountLabel(entry.actor)
: t("recordSharing.activity.deletedAccount");
return (
<li
data-slot="record-activity-row"
className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 py-2"
>
<span className="text-foreground text-sm">
{entry.accesses === null
? t("recordSharing.activity.acted", { name: who })
: t("recordSharing.activity.opened", {
name: who,
count: entry.accesses,
})}
</span>
<span className="text-muted-foreground text-xs">
{fmt.dateTime(new Date(entry.createdAt))}
</span>
</li>
);
}
@@ -39,6 +39,7 @@ import {
Thermometer,
TrendingUp,
User,
UsersRound,
type LucideIcon,
} from "lucide-react";
@@ -49,6 +50,7 @@ import type { SettingsSectionSlug } from "./section-slugs";
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
account: User,
security: ShieldCheck,
access: UsersRound,
modules: Blocks,
integrations: Link2,
sources: Layers,
+8
View File
@@ -53,6 +53,14 @@ export const SETTINGS_SECTION_SLUGS = [
// setup (TOTP + security keys), recovery codes, and passkey management. Sits
// right after `account` so the two identity surfaces read as neighbours.
"security",
// v1.36.0 — `access` ("Shared access"): which other accounts on this
// instance may open this health record, which records this one may open, and
// when they were opened. Sits beside `security` because it answers the same
// question those surfaces do — who can get at my account, and what have they
// been doing. Distinct from the `sharing` slug below, which is the clinician
// share LINK (a time-boxed read-only report handed to somebody outside the
// instance); this one is a standing relationship between two accounts on it.
"access",
// v1.18.0 — `modules` ("Was du trackst") sits right after account: the
// single front door for enabling/disabling secondary tracking domains.
"modules",
@@ -38,6 +38,7 @@ import {
SlidersHorizontal,
Sparkles,
User,
UsersRound,
type LucideIcon,
} from "lucide-react";
@@ -150,6 +151,16 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
icon: ShieldCheck,
group: "account",
},
// v1.36.0 — who else may open this record, and whose this account may open.
// In the Account group beside Security: both answer "who can get at my
// account and what have they done", and a person looking for one will look
// where the other is.
{
slug: "access",
titleKey: "settings.sections.access.title",
icon: UsersRound,
group: "account",
},
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one coherent privacy pane.
{
+152
View File
@@ -0,0 +1,152 @@
"use client";
/**
* v1.36.0 — the reads and writes behind the shared-access panel.
*
* One module for the whole surface, because every mutation here invalidates
* the same three things and the standing rule about cache invalidation is
* easiest to keep when there is one place that can break it. `grantDependentKeys`
* (`@/lib/query-keys`) is that place: the grant list, the owner's activity
* feed, and the account payload — the last because `accountAccess` is what
* paints the switcher and the banner, and a revoke that refreshed the panel but
* not the payload leaves a switcher entry pointing at a record the server has
* just closed.
*
* Nothing in here decides anything. `state` arrives resolved from the same
* function the request resolver's own predicate is built on, so the panel
* cannot render "active" on a row the server has stopped honouring.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { grantDependentKeys, queryKeys } from "@/lib/query-keys";
/** The other party to a grant, as far as anyone is told about them. */
export interface GrantParty {
id: string;
username: string;
displayName: string | null;
}
export type GrantState = "PENDING" | "ACTIVE" | "EXPIRED" | "REVOKED";
export interface GrantRow {
id: string;
account: GrantParty;
access: "READ" | "WRITE";
state: GrantState;
invitedAt: string;
acceptedAt: string | null;
expiresAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
revokedBy: "GRANTOR" | "GRANTEE" | null;
}
export interface GrantList {
/** Grants this account has offered on its own record. */
given: GrantRow[];
/** Grants offered to this account on somebody else's. */
received: GrantRow[];
}
export interface RecordActivityEntry {
id: string;
actor: GrantParty | null;
action: string;
accesses: number | null;
createdAt: string;
}
export interface RecordActivity {
entries: RecordActivityEntry[];
/**
* How far back the feed can see, in days — resolved by the server from the
* same setting the purge job reads. The view states it rather than implying
* the list is everything that ever happened.
*/
retentionDays: number;
}
export function useAccountGrants(enabled = true) {
return useQuery({
queryKey: queryKeys.accountGrants(),
queryFn: () => apiGet<GrantList>("/api/account/grants"),
enabled,
});
}
export function useRecordActivity(enabled = true) {
return useQuery({
queryKey: queryKeys.accountActivity(),
queryFn: () => apiGet<RecordActivity>("/api/account/activity"),
enabled,
});
}
/**
* Refresh everything a grant transition can have changed.
*
* Shared by all four mutations rather than written out per hook: the day one
* of them forgets the account payload is the day a revoked record keeps its
* switcher entry.
*/
function useInvalidateGrants() {
const queryClient = useQueryClient();
return () => {
for (const key of grantDependentKeys) {
void queryClient.invalidateQueries({ queryKey: key });
}
};
}
export interface InviteInput {
identifier: string;
/** ISO-8601 with offset, or null for a grant that runs until somebody ends it. */
expiresAt: string | null;
}
export function useInviteGrant() {
const invalidate = useInvalidateGrants();
return useMutation({
mutationKey: queryKeys.accountGrantInvite(),
mutationFn: (input: InviteInput) =>
apiPost<GrantRow>("/api/account/grants", input),
onSuccess: invalidate,
});
}
export function useAcceptGrant() {
const invalidate = useInvalidateGrants();
return useMutation({
mutationKey: queryKeys.accountGrantAccept(),
mutationFn: (grantId: string) =>
apiPost<GrantRow>(`/api/account/grants/${grantId}/accept`),
onSuccess: invalidate,
});
}
export function useRevokeGrant() {
const invalidate = useInvalidateGrants();
return useMutation({
mutationKey: queryKeys.accountGrantRevoke(),
mutationFn: (grantId: string) =>
apiDelete<GrantRow & { sessionsCleared: number }>(
`/api/account/grants/${grantId}`,
),
onSuccess: invalidate,
});
}
export function useRenounceGrant() {
const invalidate = useInvalidateGrants();
return useMutation({
mutationKey: queryKeys.accountGrantRenounce(),
mutationFn: (grantId: string) =>
apiPost<GrantRow & { sessionsCleared: number }>(
`/api/account/grants/${grantId}/renounce`,
),
onSuccess: invalidate,
});
}