diff --git a/messages/de.json b/messages/de.json index 1d8f1da72..cf0d5543b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" + } } } diff --git a/messages/en.json b/messages/en.json index b44d7e40b..277ac0349 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" + } } } diff --git a/messages/es.json b/messages/es.json index b3f0f4338..e8b81c7b3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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" + } } } diff --git a/messages/fr.json b/messages/fr.json index 160b2706a..3f24e137c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é" + } } } diff --git a/messages/it.json b/messages/it.json index 8713772d7..029d65e4d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" + } } } diff --git a/messages/pl.json b/messages/pl.json index 7c9cfb017..3ef29e205 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" + } } } diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index a23fdcdd8..2eeab45bb 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -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", diff --git a/src/app/api/account/activity/route.ts b/src/app/api/account/activity/route.ts new file mode 100644 index 000000000..993cf473a --- /dev/null +++ b/src/app/api/account/activity/route.ts @@ -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(), + }); +}); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 904500df1..4df2a7c55 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -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, diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 8970f8ae0..25f80a4c4 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -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", diff --git a/src/components/settings/access-section.tsx b/src/components/settings/access-section.tsx new file mode 100644 index 000000000..0925b34bb --- /dev/null +++ b/src/components/settings/access-section.tsx @@ -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 ( +
+ + + + +
+ ); +} diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx new file mode 100644 index 000000000..1affa9a96 --- /dev/null +++ b/src/components/settings/access/grant-invite-card.tsx @@ -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(null); + const [invited, setInvited] = useState(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 ( + + +
+
+ + setIdentifier(e.target.value)} + placeholder={t("recordSharing.invite.identifierPlaceholder")} + autoComplete="off" + maxLength={255} + /> +

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

+
+ + {error && ( +

+ {error} +

+ )} + {invited && ( +

+ {t("recordSharing.invite.sent", { name: invited })} +

+ )} +
+
+ ); +} + +/** + * 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"; +} diff --git a/src/components/settings/access/grant-row.tsx b/src/components/settings/access/grant-row.tsx new file mode 100644 index 000000000..4825b6bd7 --- /dev/null +++ b/src/components/settings/access/grant-row.tsx @@ -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 ( +
  • +
    +
    + + {accountLabel(grant.account)} + + + {t(`recordSharing.state.${stateKey(grant.state)}`)} + +
    +

    + {grant.access === "WRITE" + ? t("recordSharing.row.canWrite") + : t("recordSharing.row.readOnly")} +

    +
    +
    +
    {t("recordSharing.row.invited")}
    +
    {fmt.dateTime(new Date(grant.invitedAt))}
    +
    + {grant.acceptedAt && ( +
    +
    {t("recordSharing.row.accepted")}
    +
    {fmt.dateTime(new Date(grant.acceptedAt))}
    +
    + )} + {grant.expiresAt && !ended && ( +
    +
    {t("recordSharing.row.expires")}
    +
    {fmt.dateTime(new Date(grant.expiresAt))}
    +
    + )} + {grant.state === "ACTIVE" && ( +
    +
    {t("recordSharing.row.lastUsed")}
    + {/* Absence reads as absence: a grant that has never been + exercised says so, rather than showing a blank. */} +
    + {grant.lastUsedAt + ? fmt.dateTime(new Date(grant.lastUsedAt)) + : t("recordSharing.row.neverUsed")} +
    +
    + )} + {grant.revokedAt && ( +
    +
    {endedByKey(grant.revokedBy, side, t)}
    +
    {fmt.dateTime(new Date(grant.revokedAt))}
    +
    + )} +
    +
    + {actions &&
    {actions}
    } +
  • + ); +} + +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"); +} diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx new file mode 100644 index 000000000..756447179 --- /dev/null +++ b/src/components/settings/access/grants-given-card.tsx @@ -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 ( + + +
    + {isLoading && ( + + )} + {isError && ( + void refetch()} + /> + )} + {!isLoading && !isError && given.length === 0 && ( + } + title={t("recordSharing.given.emptyTitle")} + description={t("recordSharing.given.emptyDescription")} + /> + )} + {given.length > 0 && ( +
      + {given.map((grant) => ( + + ))} +
    + )} +
    +
    + ); + + 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 ( + revoke.mutate(grant.id)} + /> + ); + } +} diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx new file mode 100644 index 000000000..73ad296fe --- /dev/null +++ b/src/components/settings/access/grants-received-card.tsx @@ -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 ( + + +
    + {isLoading && ( + + )} + {isError && ( + void refetch()} + /> + )} + {!isLoading && !isError && received.length === 0 && ( + } + title={t("recordSharing.received.emptyTitle")} + description={t("recordSharing.received.emptyDescription")} + /> + )} + {received.length > 0 && ( +
      + {received.map((grant) => ( + + ))} +
    + )} +
    +
    + ); + + function renderActions(grant: GrantRow) { + if (grant.state === "PENDING") { + return ( + + ); + } + if (grant.state !== "ACTIVE") return null; + const name = accountLabel(grant.account); + return ( +
    + + renounce.mutate(grant.id)} + /> +
    + ); + } +} diff --git a/src/components/settings/access/record-activity-card.tsx b/src/components/settings/access/record-activity-card.tsx new file mode 100644 index 000000000..066ce1e30 --- /dev/null +++ b/src/components/settings/access/record-activity-card.tsx @@ -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 ( + + +
    + {/* 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 && ( +

    + {t("recordSharing.activity.retention", { + days: data.retentionDays, + })} +

    + )} + + {isLoading && ( + + )} + {isError && ( + void refetch()} + /> + )} + {!isLoading && !isError && entries.length === 0 && ( + } + title={t("recordSharing.activity.emptyTitle")} + description={t("recordSharing.activity.emptyDescription")} + /> + )} + {entries.length > 0 && ( +
      + {entries.map((entry) => ( + + ))} +
    + )} +
    +
    + ); +} + +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 ( +
  • + + {entry.accesses === null + ? t("recordSharing.activity.acted", { name: who }) + : t("recordSharing.activity.opened", { + name: who, + count: entry.accesses, + })} + + + {fmt.dateTime(new Date(entry.createdAt))} + +
  • + ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index ae891f2ec..eff2f4430 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -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 = { account: User, security: ShieldCheck, + access: UsersRound, modules: Blocks, integrations: Link2, sources: Layers, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 0f43f0b8a..d73f5eb53 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -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", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 7bbe37097..e574259f5 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -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. { diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts new file mode 100644 index 000000000..2041c447f --- /dev/null +++ b/src/lib/queries/use-account-grants.ts @@ -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("/api/account/grants"), + enabled, + }); +} + +export function useRecordActivity(enabled = true) { + return useQuery({ + queryKey: queryKeys.accountActivity(), + queryFn: () => apiGet("/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("/api/account/grants", input), + onSuccess: invalidate, + }); +} + +export function useAcceptGrant() { + const invalidate = useInvalidateGrants(); + return useMutation({ + mutationKey: queryKeys.accountGrantAccept(), + mutationFn: (grantId: string) => + apiPost(`/api/account/grants/${grantId}/accept`), + onSuccess: invalidate, + }); +} + +export function useRevokeGrant() { + const invalidate = useInvalidateGrants(); + return useMutation({ + mutationKey: queryKeys.accountGrantRevoke(), + mutationFn: (grantId: string) => + apiDelete( + `/api/account/grants/${grantId}`, + ), + onSuccess: invalidate, + }); +} + +export function useRenounceGrant() { + const invalidate = useInvalidateGrants(); + return useMutation({ + mutationKey: queryKeys.accountGrantRenounce(), + mutationFn: (grantId: string) => + apiPost( + `/api/account/grants/${grantId}/renounce`, + ), + onSuccess: invalidate, + }); +}