From d902125371b208336da0c8a7a39b1db55dc8d719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 14:28:15 +0100 Subject: [PATCH] feat(account): tell admins about a refused erasure, and what undo leaves revoked When the grace window ends for an account that has become the last active guardian of a managed profile, the purge job refuses to erase it and the account stays locked. Until now only the worker error log said so. Every admin now gets one localized notification naming the account and the ways out (another guardian, deleting the profile, or undoing the deletion). An audit row on the account records that it was reported, so the hourly retries stay quiet; a deletion undone and asked for again is reported afresh. The undo confirmation in the admin users list now says that sign-in works again but API tokens, share links and trusted devices stay revoked and have to be set up again (for a managed profile: its share links and tokens). --- messages/de.json | 8 ++- messages/en.json | 8 ++- messages/es.json | 8 ++- messages/fr.json | 8 ++- messages/it.json | 8 ++- messages/ko.json | 8 ++- messages/nl.json | 8 ++- messages/pl.json | 8 ++- messages/pt-BR.json | 8 ++- src/lib/jobs/account-deletion-purge.ts | 53 +++++++++++++- .../account-deletion-grace.test.ts | 69 +++++++++++++++++++ 11 files changed, 165 insertions(+), 29 deletions(-) diff --git a/messages/de.json b/messages/de.json index 593ca7c83..b418e928e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8096,8 +8096,8 @@ "deletionPendingNoDate": "Löschung vorgemerkt", "undoDeletion": "Löschung rückgängig machen", "undoDeletionTitle": "Löschung von {name} rückgängig machen?", - "undoDeletionBody": "{name} hat die Löschung dieses Kontos verlangt; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. Seine Sessions und Tokens bleiben widerrufen, die Anmeldung ist also neu nötig.", - "undoDeletionBodyGuardian": "Eine betreuende Person hat das verwaltete Profil {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Profil und alles darin so, wie es war, und die betreuenden Personen erreichen es wieder.", + "undoDeletionBody": "{name} hat die Löschung dieses Kontos verlangt; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.", + "undoDeletionBodyGuardian": "Eine betreuende Person hat das verwaltete Profil {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Profil und alles darin so, wie es war, und die betreuenden Personen erreichen es wieder. Seine Freigabe-Links und Tokens bleiben widerrufen und müssen neu eingerichtet werden.", "undoDeletionDone": "Löschung von {name} rückgängig gemacht", "undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen", "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte über die Online-Abfrage bei {host} auf, damit verlässt jede Login-IP diesen Server. Um sie stattdessen lokal aufzulösen, lade die Datenbanken GeoLite2-City und GeoLite2-ASN mit einem kostenlosen MaxMind-Konto herunter, binde das Verzeichnis in den Container ein und setze GEOLITE2_DIR darauf. Anleitung: {docsUrl}", "tlsPinChangedTitle": "TLS-Leaf-Zertifikat geändert: {host}", - "tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md." + "tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md.", + "accountErasureRefusedTitle": "Konto konnte nicht gelöscht werden: {username}", + "accountErasureRefusedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Konto ist die letzte aktive betreuende Person eines verwalteten Profils und wurde deshalb nicht gelöscht. Es bleibt gesperrt. Füge dem Profil eine weitere betreuende Person hinzu, lösche das Profil oder mache die Löschung unter Administration, Nutzer rückgängig." }, "user": { "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv." diff --git a/messages/en.json b/messages/en.json index e9aaa311f..5da2c7cb3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8096,8 +8096,8 @@ "deletionPendingNoDate": "Deletion pending", "undoDeletion": "Undo deletion", "undoDeletionTitle": "Undo the deletion of {name}?", - "undoDeletionBody": "{name} asked to delete this account, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. Its sessions and tokens stay revoked, so {name} signs in again.", - "undoDeletionBodyGuardian": "A guardian deleted the managed profile {name}, and it is erased when the grace window ends. Undoing keeps the profile and everything in it as it was, and its guardians can reach it again.", + "undoDeletionBody": "{name} asked to delete this account, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. {name} can sign in again, but API tokens, share links and trusted devices stay revoked and have to be set up again.", + "undoDeletionBodyGuardian": "A guardian deleted the managed profile {name}, and it is erased when the grace window ends. Undoing keeps the profile and everything in it as it was, and its guardians can reach it again. Its share links and tokens stay revoked and have to be set up again.", "undoDeletionDone": "Deletion undone for {name}", "undoDeletionFailed": "Could not undo the deletion" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "Offline geolocation databases not loaded", "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online lookup at {host}, so every login IP address leaves this server. To resolve them locally instead, download the GeoLite2-City and GeoLite2-ASN databases with a free MaxMind account, mount the directory into the container, and point GEOLITE2_DIR at it. Steps: {docsUrl}", "tlsPinChangedTitle": "TLS leaf certificate changed: {host}", - "tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md." + "tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md.", + "accountErasureRefusedTitle": "Account could not be erased: {username}", + "accountErasureRefusedBody": "The deletion window of {username} has ended, but the account is the last active guardian of a managed profile, so it was not erased. It stays locked. Add another guardian to the profile, delete the profile, or undo the deletion in Administration, Users." }, "user": { "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active." diff --git a/messages/es.json b/messages/es.json index 2901074ee..e530760ee 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8096,8 +8096,8 @@ "deletionPendingNoDate": "Eliminación pendiente", "undoDeletion": "Deshacer eliminación", "undoDeletionTitle": "¿Deshacer la eliminación de {name}?", - "undoDeletionBody": "{name} pidió eliminar esta cuenta, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. Sus sesiones y tokens siguen revocados, así que habrá que volver a iniciar sesión.", - "undoDeletionBodyGuardian": "Una persona responsable eliminó el perfil gestionado {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva el perfil y todo su contenido tal como estaba, y las personas responsables vuelven a tener acceso.", + "undoDeletionBody": "{name} pidió eliminar esta cuenta, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. {name} puede volver a iniciar sesión, pero los tokens de API, los enlaces compartidos y los dispositivos de confianza siguen revocados y hay que configurarlos de nuevo.", + "undoDeletionBodyGuardian": "Una persona responsable eliminó el perfil gestionado {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva el perfil y todo su contenido tal como estaba, y las personas responsables vuelven a tener acceso. Sus enlaces compartidos y tokens siguen revocados y hay que configurarlos de nuevo.", "undoDeletionDone": "Eliminación de {name} deshecha", "undoDeletionFailed": "No se pudo deshacer la eliminación" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas", "offlineGeoUnavailableBody": "HealthLog resuelve las ubicaciones de inicio de sesión mediante la consulta en línea a {host}, por lo que cada IP de inicio de sesión sale de este servidor. Para resolverlas localmente, descarga las bases GeoLite2-City y GeoLite2-ASN con una cuenta gratuita de MaxMind, monta el directorio en el contenedor y apunta GEOLITE2_DIR a él. Pasos: {docsUrl}", "tlsPinChangedTitle": "Certificado de hoja TLS cambiado: {host}", - "tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md." + "tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md.", + "accountErasureRefusedTitle": "No se pudo borrar la cuenta: {username}", + "accountErasureRefusedBody": "El plazo de eliminación de {username} ha terminado, pero la cuenta es la última persona responsable activa de un perfil gestionado, así que no se ha borrado. Sigue bloqueada. Añade otra persona responsable al perfil, elimina el perfil o deshaz la eliminación en Administration, Usuarios." }, "user": { "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas." diff --git a/messages/fr.json b/messages/fr.json index 6fbdaa48e..b2ee34e55 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8096,8 +8096,8 @@ "deletionPendingNoDate": "Suppression programmée", "undoDeletion": "Annuler la suppression", "undoDeletionTitle": "Annuler la suppression de {name} ?", - "undoDeletionBody": "{name} a demandé la suppression de ce compte, qui sera effacé à la fin du délai de grâce. Annuler conserve le compte et tout son contenu tels quels. Ses sessions et jetons restent révoqués : il faudra se reconnecter.", - "undoDeletionBodyGuardian": "Une personne responsable a supprimé le profil géré {name}, qui sera effacé à la fin du délai de grâce. Annuler conserve le profil et tout son contenu tels quels, et les personnes responsables y ont de nouveau accès.", + "undoDeletionBody": "{name} a demandé la suppression de ce compte, qui sera effacé à la fin du délai de grâce. Annuler conserve le compte et tout son contenu tels quels. {name} peut se reconnecter, mais les jetons d’API, les liens de partage et les appareils de confiance restent révoqués et doivent être configurés à nouveau.", + "undoDeletionBodyGuardian": "Une personne responsable a supprimé le profil géré {name}, qui sera effacé à la fin du délai de grâce. Annuler conserve le profil et tout son contenu tels quels, et les personnes responsables y ont de nouveau accès. Ses liens de partage et jetons restent révoqués et doivent être configurés à nouveau.", "undoDeletionDone": "Suppression de {name} annulée", "undoDeletionFailed": "Impossible d’annuler la suppression" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées", "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via la requête en ligne vers {host}, donc chaque adresse IP de connexion quitte ce serveur. Pour les résoudre localement, téléchargez les bases GeoLite2-City et GeoLite2-ASN avec un compte MaxMind gratuit, montez le répertoire dans le conteneur et faites-y pointer GEOLITE2_DIR. Étapes : {docsUrl}", "tlsPinChangedTitle": "Certificat feuille TLS modifié : {host}", - "tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md." + "tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md.", + "accountErasureRefusedTitle": "Le compte n’a pas pu être effacé : {username}", + "accountErasureRefusedBody": "Le délai de suppression de {username} est écoulé, mais ce compte est la dernière personne responsable active d’un profil géré : il n’a donc pas été effacé. Il reste verrouillé. Ajoutez une autre personne responsable au profil, supprimez le profil ou annulez la suppression dans Administration, Utilisateurs." }, "user": { "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives." diff --git a/messages/it.json b/messages/it.json index 798c1b79a..9b723851a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8096,8 +8096,8 @@ "deletionPendingNoDate": "Eliminazione in attesa", "undoDeletion": "Annulla eliminazione", "undoDeletionTitle": "Annullare l’eliminazione di {name}?", - "undoDeletionBody": "{name} ha chiesto di eliminare questo account, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. Sessioni e token restano revocati, quindi servirà un nuovo accesso.", - "undoDeletionBodyGuardian": "Una persona responsabile ha eliminato il profilo gestito {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene il profilo e tutto il suo contenuto com’era, e le persone responsabili possono di nuovo accedervi.", + "undoDeletionBody": "{name} ha chiesto di eliminare questo account, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. {name} può accedere di nuovo, ma i token API, i link di condivisione e i dispositivi attendibili restano revocati e vanno configurati di nuovo.", + "undoDeletionBodyGuardian": "Una persona responsabile ha eliminato il profilo gestito {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene il profilo e tutto il suo contenuto com’era, e le persone responsabili possono di nuovo accedervi. I suoi link di condivisione e token restano revocati e vanno configurati di nuovo.", "undoDeletionDone": "Eliminazione di {name} annullata", "undoDeletionFailed": "Impossibile annullare l’eliminazione" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati", "offlineGeoUnavailableBody": "HealthLog risolve le posizioni di accesso tramite la ricerca online su {host}, quindi ogni IP di accesso lascia questo server. Per risolverle localmente, scarica i database GeoLite2-City e GeoLite2-ASN con un account MaxMind gratuito, monta la directory nel container e fai puntare GEOLITE2_DIR ad essa. Passaggi: {docsUrl}", "tlsPinChangedTitle": "Certificato foglia TLS modificato: {host}", - "tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md." + "tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md.", + "accountErasureRefusedTitle": "Impossibile cancellare l’account: {username}", + "accountErasureRefusedBody": "Il periodo di eliminazione di {username} è terminato, ma l’account è l’ultima persona responsabile attiva di un profilo gestito, quindi non è stato cancellato. Resta bloccato. Aggiungi un’altra persona responsabile al profilo, elimina il profilo o annulla l’eliminazione in Administration, Utenti." }, "user": { "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive." diff --git a/messages/ko.json b/messages/ko.json index 6e38b7d5b..c374b6cd2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8071,8 +8071,8 @@ "deletionPendingNoDate": "삭제 예정", "undoDeletion": "삭제 취소", "undoDeletionTitle": "{name} 삭제를 취소할까요?", - "undoDeletionBody": "{name} 님이 이 계정의 삭제를 요청했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. 세션과 토큰은 계속 취소된 상태라 다시 로그인해야 해요.", - "undoDeletionBodyGuardian": "보호자가 관리 프로필 {name}을(를) 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 프로필과 그 안의 모든 내용이 그대로 남고, 보호자들이 다시 접근할 수 있어요.", + "undoDeletionBody": "{name} 님이 이 계정의 삭제를 요청했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.", + "undoDeletionBodyGuardian": "보호자가 관리 프로필 {name}을(를) 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 프로필과 그 안의 모든 내용이 그대로 남고, 보호자들이 다시 접근할 수 있어요. 공유 링크와 토큰은 계속 취소된 상태라 다시 설정해야 해요.", "undoDeletionDone": "{name} 삭제를 취소했어요", "undoDeletionFailed": "삭제를 취소하지 못했어요" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "오프라인 위치 데이터베이스가 로드되지 않았어요", "offlineGeoUnavailableBody": "HealthLog가 {host}의 온라인 조회로 로그인 위치를 확인하고 있어서, 모든 로그인 IP 주소가 이 서버를 벗어나요. 로컬에서 확인하려면 무료 MaxMind 계정으로 GeoLite2-City와 GeoLite2-ASN 데이터베이스를 내려받아 그 디렉터리를 컨테이너에 마운트하고 GEOLITE2_DIR가 이를 가리키게 하세요. 절차: {docsUrl}", "tlsPinChangedTitle": "TLS 리프 인증서 변경: {host}", - "tlsPinChangedBody": "{host}가 제공하는 TLS 리프의 SPKI 핀이 이제 {servedPin}인데, 알려진 핀 목록에 없어요. 기존 핀이 사라지면 핀을 검증하는 클라이언트(네이티브 앱)는 연결에 실패해요. 새 리프 핀을 다시 추출해 TLS_LEAF_SPKI_PINS와 네이티브 클라이언트 핀 목록에 추가하고, 이 인증서가 만료되는 {validTo} 전에 빌드를 배포하세요. docs/ops/tls-cert-pin.md를 참고하세요." + "tlsPinChangedBody": "{host}가 제공하는 TLS 리프의 SPKI 핀이 이제 {servedPin}인데, 알려진 핀 목록에 없어요. 기존 핀이 사라지면 핀을 검증하는 클라이언트(네이티브 앱)는 연결에 실패해요. 새 리프 핀을 다시 추출해 TLS_LEAF_SPKI_PINS와 네이티브 클라이언트 핀 목록에 추가하고, 이 인증서가 만료되는 {validTo} 전에 빌드를 배포하세요. docs/ops/tls-cert-pin.md를 참고하세요.", + "accountErasureRefusedTitle": "계정을 지우지 못했어요: {username}", + "accountErasureRefusedBody": "{username} 계정의 삭제 유예 기간이 끝났지만, 이 계정이 관리 프로필의 마지막 활성 보호자라서 지우지 않았어요. 계정은 계속 잠겨 있어요. 프로필에 다른 보호자를 추가하거나, 프로필을 삭제하거나, 관리자의 사용자 화면에서 삭제를 취소해 주세요." }, "user": { "telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요." diff --git a/messages/nl.json b/messages/nl.json index 7152c3151..9cfb3b429 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8096,8 +8096,8 @@ "deletionPendingNoDate": "Verwijdering gepland", "undoDeletion": "Verwijdering ongedaan maken", "undoDeletionTitle": "Verwijdering van {name} ongedaan maken?", - "undoDeletionBody": "{name} heeft gevraagd dit account te verwijderen; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. Sessies en tokens blijven ingetrokken, dus opnieuw inloggen is nodig.", - "undoDeletionBodyGuardian": "Een verzorger heeft het beheerde profiel {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het profiel en alles erin zoals het was, en de verzorgers kunnen het weer bereiken.", + "undoDeletionBody": "{name} heeft gevraagd dit account te verwijderen; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. {name} kan weer inloggen, maar API-tokens, deellinks en vertrouwde apparaten blijven ingetrokken en moeten opnieuw worden ingesteld.", + "undoDeletionBodyGuardian": "Een verzorger heeft het beheerde profiel {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het profiel en alles erin zoals het was, en de verzorgers kunnen het weer bereiken. De deellinks en tokens blijven ingetrokken en moeten opnieuw worden ingesteld.", "undoDeletionDone": "Verwijdering van {name} ongedaan gemaakt", "undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "Offline geolocatiedatabases niet geladen", "offlineGeoUnavailableBody": "HealthLog bepaalt inloglocaties via de online opzoekdienst op {host}, dus elk IP-adres waarmee wordt ingelogd, verlaat deze server. Wil je ze lokaal bepalen, download dan de databases GeoLite2-City en GeoLite2-ASN met een gratis MaxMind-account, koppel de map aan de container en laat GEOLITE2_DIR ernaar verwijzen. Stappen: {docsUrl}", "tlsPinChangedTitle": "TLS-leafcertificaat gewijzigd: {host}", - "tlsPinChangedBody": "Het TLS-leafcertificaat dat {host} aanbiedt, heeft nu SPKI-pin {servedPin}, die niet in de set bekende pins staat. Clients met pinning (de native app) kunnen geen verbinding meer maken zodra de oude pin verdwenen is. Haal de nieuwe leaf-pin opnieuw op, voeg hem toe aan TLS_LEAF_SPKI_PINS en aan de pinset van de native client, en breng een build uit voordat dit certificaat op {validTo} verloopt. Zie docs/ops/tls-cert-pin.md." + "tlsPinChangedBody": "Het TLS-leafcertificaat dat {host} aanbiedt, heeft nu SPKI-pin {servedPin}, die niet in de set bekende pins staat. Clients met pinning (de native app) kunnen geen verbinding meer maken zodra de oude pin verdwenen is. Haal de nieuwe leaf-pin opnieuw op, voeg hem toe aan TLS_LEAF_SPKI_PINS en aan de pinset van de native client, en breng een build uit voordat dit certificaat op {validTo} verloopt. Zie docs/ops/tls-cert-pin.md.", + "accountErasureRefusedTitle": "Account kon niet worden gewist: {username}", + "accountErasureRefusedBody": "De verwijdertermijn van {username} is afgelopen, maar het account is de laatste actieve verzorger van een beheerd profiel en is daarom niet gewist. Het blijft vergrendeld. Voeg een andere verzorger toe aan het profiel, verwijder het profiel of maak de verwijdering ongedaan in Beheer, Gebruikers." }, "user": { "telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief." diff --git a/messages/pl.json b/messages/pl.json index 39dd8dda7..a9bd2fe69 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8096,8 +8096,8 @@ "deletionPendingNoDate": "Usunięcie zaplanowane", "undoDeletion": "Cofnij usunięcie", "undoDeletionTitle": "Cofnąć usunięcie {name}?", - "undoDeletionBody": "{name} poprosił o usunięcie tego konta; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. Sesje i tokeny pozostają unieważnione, więc trzeba będzie zalogować się ponownie.", - "undoDeletionBodyGuardian": "Osoba opiekująca się profilem usunęła profil zarządzany {name}; zostanie on usunięty po upływie okresu karencji. Cofnięcie zachowuje profil i całą jego zawartość bez zmian, a osoby opiekujące się nim znów mają do niego dostęp.", + "undoDeletionBody": "{name} poprosił o usunięcie tego konta; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. {name} może znów się zalogować, ale tokeny API, linki udostępniania i zaufane urządzenia pozostają unieważnione i trzeba je skonfigurować od nowa.", + "undoDeletionBodyGuardian": "Osoba opiekująca się profilem usunęła profil zarządzany {name}; zostanie on usunięty po upływie okresu karencji. Cofnięcie zachowuje profil i całą jego zawartość bez zmian, a osoby opiekujące się nim znów mają do niego dostęp. Jego linki udostępniania i tokeny pozostają unieważnione i trzeba je skonfigurować od nowa.", "undoDeletionDone": "Cofnięto usunięcie {name}", "undoDeletionFailed": "Nie udało się cofnąć usunięcia" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane", "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez zapytanie online do {host}, więc każdy adres IP logowania opuszcza ten serwer. Aby rozwiązywać je lokalnie, pobierz bazy GeoLite2-City i GeoLite2-ASN na bezpłatnym koncie MaxMind, podłącz katalog do kontenera i ustaw na niego GEOLITE2_DIR. Kroki: {docsUrl}", "tlsPinChangedTitle": "Zmieniono certyfikat liścia TLS: {host}", - "tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md." + "tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md.", + "accountErasureRefusedTitle": "Nie udało się usunąć konta: {username}", + "accountErasureRefusedBody": "Okres usuwania konta {username} minął, ale to konto jest ostatnią aktywną osobą opiekującą się profilem zarządzanym, więc nie zostało usunięte. Pozostaje zablokowane. Dodaj do profilu kolejną osobę opiekującą się, usuń profil albo cofnij usunięcie w Administracja, Użytkownicy." }, "user": { "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne." diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 956b87db2..6375168eb 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8096,8 +8096,8 @@ "deletionPendingNoDate": "Exclusão pendente", "undoDeletion": "Desfazer exclusão", "undoDeletionTitle": "Desfazer a exclusão de {name}?", - "undoDeletionBody": "{name} pediu para excluir esta conta, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. As sessões e tokens continuam revogados, então será preciso entrar de novo.", - "undoDeletionBodyGuardian": "Um guardião excluiu o perfil gerenciado {name}, e ele é apagado quando o prazo de carência termina. Desfazer mantém o perfil e tudo o que há nele como estava, e os guardiões voltam a ter acesso.", + "undoDeletionBody": "{name} pediu para excluir esta conta, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. {name} pode entrar de novo, mas os tokens de API, os links de compartilhamento e os dispositivos confiáveis continuam revogados e precisam ser configurados de novo.", + "undoDeletionBodyGuardian": "Um guardião excluiu o perfil gerenciado {name}, e ele é apagado quando o prazo de carência termina. Desfazer mantém o perfil e tudo o que há nele como estava, e os guardiões voltam a ter acesso. Os links de compartilhamento e tokens continuam revogados e precisam ser configurados de novo.", "undoDeletionDone": "Exclusão de {name} desfeita", "undoDeletionFailed": "Não foi possível desfazer a exclusão" }, @@ -8911,7 +8911,9 @@ "offlineGeoUnavailableTitle": "Bancos de dados de geolocalização offline não carregados", "offlineGeoUnavailableBody": "O HealthLog está resolvendo locais de login pela consulta online em {host}, então cada endereço IP de login sai deste servidor. Para resolvê-los localmente, baixe os bancos de dados GeoLite2-City e GeoLite2-ASN com uma conta gratuita da MaxMind, monte o diretório no contêiner e aponte GEOLITE2_DIR para ele. Passos: {docsUrl}", "tlsPinChangedTitle": "Certificado folha TLS alterado: {host}", - "tlsPinChangedBody": "O certificado folha TLS servido por {host} agora tem o pin SPKI {servedPin}, que não está no conjunto de pins conhecidos. Clientes com pin (o aplicativo nativo) falharão ao conectar quando o pin antigo sumir. Extraia novamente o pin da nova folha, adicione-o a TLS_LEAF_SPKI_PINS e ao conjunto de pins do cliente nativo, e publique uma build antes que este certificado expire em {validTo}. Veja docs/ops/tls-cert-pin.md." + "tlsPinChangedBody": "O certificado folha TLS servido por {host} agora tem o pin SPKI {servedPin}, que não está no conjunto de pins conhecidos. Clientes com pin (o aplicativo nativo) falharão ao conectar quando o pin antigo sumir. Extraia novamente o pin da nova folha, adicione-o a TLS_LEAF_SPKI_PINS e ao conjunto de pins do cliente nativo, e publique uma build antes que este certificado expire em {validTo}. Veja docs/ops/tls-cert-pin.md.", + "accountErasureRefusedTitle": "Não foi possível apagar a conta: {username}", + "accountErasureRefusedBody": "O prazo de exclusão de {username} terminou, mas a conta é o último guardião ativo de um perfil gerenciado, então não foi apagada. Ela continua bloqueada. Adicione outro guardião ao perfil, exclua o perfil ou desfaça a exclusão em Administração, Usuários." }, "user": { "telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas." diff --git a/src/lib/jobs/account-deletion-purge.ts b/src/lib/jobs/account-deletion-purge.ts index 9bb6377e1..32b5cf3f2 100644 --- a/src/lib/jobs/account-deletion-purge.ts +++ b/src/lib/jobs/account-deletion-purge.ts @@ -16,8 +16,8 @@ * account is already gone and the undo answers 404). The Guardian invariant * is decided again too: a Guardian whose last co-Guardian left during the * window would leave a managed profile nobody can reach, so that erasure is - * refused, the account stays locked and pending, and the run reports it for - * the operator to settle. + * refused, the account stays locked and pending, the run reports it, and + * every admin is told once through the localized admin notification. */ import type { Job } from "pg-boss"; @@ -37,6 +37,9 @@ import { lockPendingAccount, } from "@/lib/account-deletion/lifecycle"; import { preWipeCopyCutoff } from "@/lib/account-deletion/grace"; +import { activeAccount } from "@/lib/account-deletion/active-account"; +import { auditLog } from "@/lib/auth/audit"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; import { PRE_WIPE_BACKUP_TYPE_PREFIX } from "@/lib/account-deletion/pre-wipe-type"; import { ACCOUNT_DELETION_PURGE_QUEUE } from "./account-deletion-purge-queue"; @@ -129,6 +132,51 @@ async function deleteExpiredPreWipeCopies(now: Date): Promise { return count; } +/** The audit action that marks a refused erasure as already reported. */ +const REFUSED_ERASURE_ACTION = "user.account.deletion_refused"; + +/** + * Tell every admin, once, that an account whose window ended could not be + * erased, and why. The run retries the account every hour; the audit row on + * the account (`user.account.deletion_refused`, newer than the deletion + * request) is what keeps the second hour from repeating the message. A + * deletion undone and asked for again is a new request and is reported + * afresh. + */ +async function notifyAdminsOfRefusedErasure(accountId: string): Promise { + const account = await prisma.user.findUnique({ + where: { id: accountId }, + select: { username: true, deletionRequestedAt: true }, + }); + if (!account?.deletionRequestedAt) return; + const reported = await prisma.auditLog.count({ + where: { + userId: accountId, + action: REFUSED_ERASURE_ACTION, + createdAt: { gte: account.deletionRequestedAt }, + }, + }); + if (reported > 0) return; + await auditLog(REFUSED_ERASURE_ACTION, { + userId: accountId, + actorUserId: null, + details: { reason: "last_managed_guardian" }, + }); + const admins = await prisma.user.findMany({ + where: activeAccount({ role: "ADMIN" }), + select: { id: true }, + }); + for (const admin of admins) { + await dispatchLocalisedNotification({ + userId: admin.id, + titleKey: "notifications.admin.accountErasureRefusedTitle", + messageKey: "notifications.admin.accountErasureRefusedBody", + params: { username: account.username }, + metadata: { source: "account-deletion-purge", url: "/admin/users" }, + }); + } +} + export async function processDueAccountDeletions( now: Date = new Date(), ): Promise { @@ -159,6 +207,7 @@ export async function processDueAccountDeletions( } catch (error) { if (!(error instanceof LastManagedGuardianError)) throw error; report.refused++; + await notifyAdminsOfRefusedErasure(account.id); } } report.preWipeCopiesDeleted = await deleteExpiredPreWipeCopies(now); diff --git a/tests/integration/account-deletion-grace.test.ts b/tests/integration/account-deletion-grace.test.ts index cb7f1f94d..bddf5bda0 100644 --- a/tests/integration/account-deletion-grace.test.ts +++ b/tests/integration/account-deletion-grace.test.ts @@ -46,6 +46,15 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +const adminNotices = vi.hoisted(() => [] as Array>); +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn( + async (opts: Record) => { + adminNotices.push(opts); + }, + ), +})); + const { hashPassword } = await import("@/lib/auth/password"); const PASSWORD = "Correct horse battery staple!42"; @@ -53,6 +62,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; beforeEach(async () => { const prisma = getPrismaClient(); + adminNotices.length = 0; await truncateAllTables(prisma); await prisma.rateLimit.deleteMany(); cookieJar.clear(); @@ -372,6 +382,65 @@ describe("account deletion with a grace window", () => { }); }); +describe("an erasure the Guardian invariant refuses", () => { + it("stays locked and tells every admin once", async () => { + const prisma = getPrismaClient(); + const admin = await seedAccount("admin", "ADMIN"); + const leaving = await seedAccount("guardian-leaving"); + const staying = await seedAccount("guardian-staying"); + const profile = await prisma.user.create({ + data: { + username: "managed-kid", + managedProfileAt: new Date(), + timezone: "Europe/Berlin", + }, + }); + for (const guardian of [leaving, staying]) { + await prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: guardian.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + } + + // Allowed while another guardian holds the profile. + await signIn(leaving.id); + expect((await deleteOwnAccount()).status).toBe(200); + // The other guardian leaves during the window. + await prisma.accountGrant.updateMany({ + where: { granteeId: staying.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTEE" }, + }); + await prisma.user.update({ + where: { id: leaving.id }, + data: { deletionPurgeAt: new Date(Date.now() - 60_000) }, + }); + + const { processDueAccountDeletions } = + await import("@/lib/jobs/account-deletion-purge"); + expect(await processDueAccountDeletions(new Date())).toMatchObject({ + purged: 0, + refused: 1, + }); + // The next hourly run is refused again, and says nothing new. + expect(await processDueAccountDeletions(new Date())).toMatchObject({ + refused: 1, + }); + + const row = await prisma.user.findUnique({ where: { id: leaving.id } }); + expect(row?.deletionRequestedAt).not.toBeNull(); + expect(adminNotices).toHaveLength(1); + expect(adminNotices[0]).toMatchObject({ + userId: admin.id, + titleKey: "notifications.admin.accountErasureRefusedTitle", + params: { username: "guardian-leaving" }, + }); + }); +}); + describe("managed-profile deletion with a grace window", () => { it("locks the profile away from its Guardian until an admin undoes it", async () => { const prisma = getPrismaClient();