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 ( +
+ {grant.access === "WRITE" + ? t("recordSharing.row.canWrite") + : t("recordSharing.row.readOnly")} +
++ {t("recordSharing.activity.retention", { + days: data.retentionDays, + })} +
+ )} + + {isLoading && ( +