From 107db1bb55e51ef44d29b1dc13d08c7a74e7d968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 09:10:26 +0100 Subject: [PATCH] feat(admin): let an admin delete another account through the grace window There was no way for an admin to remove someone else's account; the users list only offered the undo for a deletion the account itself or a Guardian had asked for. DELETE /api/admin/users/{id} takes the account's own deletion path, now shared as requestAccountDeletion: with a grace window the account is locked at once (credentials revoked, record untouched) and erased by the purge job when the window ends, and the existing undo gives it back until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the request. The route is cookie-only behind requireAdmin and the recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and refuses the caller's own account, a managed profile (its Guardians delete it), an account already pending deletion, the last admin who can sign in and the last Guardian of a managed profile, the last three decided under their locks inside the transaction. Both the admin and the account get an audit row. The users list says who asked for a pending deletion, now including an admin, and flags managed profiles. Each other account's row carries a delete button whose confirmation names the purge date, or says the erasure is immediate when the server keeps no window. --- docs/api/openapi.yaml | 5 +- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ko.json | 11 +- messages/nl.json | 11 +- messages/pl.json | 11 +- messages/pt-BR.json | 11 +- .../destructive-control-guard.test.ts | 11 + .../managed-profile-contract.test.ts | 18 +- .../openapi-route-coverage-guard.test.ts | 2 +- .../recent-proof-surface-guard.test.ts | 1 + .../step-up-web-caller-guard.test.ts | 1 + src/app/api/admin/users/[id]/route.ts | 141 +++++++++- .../api/admin/users/__tests__/route.test.ts | 2 + src/app/api/admin/users/route.ts | 12 +- src/app/api/settings/account/route.ts | 74 ++--- .../user-management-responsive.test.tsx | 17 ++ src/components/admin/_shared.tsx | 10 +- src/components/admin/delete-user-control.tsx | 136 +++++++++ .../admin/pending-deletion-controls.tsx | 16 +- .../admin/user-management-section.tsx | 18 +- src/lib/account-deletion/request.ts | 81 ++++++ src/lib/openapi/error-codes.ts | 7 +- src/lib/validations/admin.ts | 8 + tests/integration/admin-user-deletion.test.ts | 264 ++++++++++++++++++ 28 files changed, 828 insertions(+), 95 deletions(-) create mode 100644 src/components/admin/delete-user-control.tsx create mode 100644 src/lib/account-deletion/request.ts create mode 100644 tests/integration/admin-user-deletion.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7eae195e0..39ecc4374 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -38543,8 +38543,9 @@ components: `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own carries the generic `http.unauthenticated`. account — - account.deletion.no_guardian, account.deletion.not_pending; activity — activity.calendar.future_date, - activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, + account.deletion.already_pending, account.deletion.no_guardian, account.deletion.not_pending; activity — + activity.calendar.future_date, activity.calendar.too_old; admin — admin.users.delete.managed_profile, + admin.users.delete.self; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.readerBusy, aiRuns.timedOut, aiRuns.workerUnavailable; diff --git a/messages/de.json b/messages/de.json index 09da70eff..cfd221364 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8110,7 +8110,16 @@ "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" + "undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden", + "undoDeletionBodyAdmin": "Ein Admin hat das Konto {name} gelöscht; 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.", + "deleteUser": "Konto löschen", + "deleteUserTitle": "Das Konto {name} löschen?", + "deleteUserBodyGrace": "{name} wird sofort überall abgemeldet und kann sich nicht mehr anmelden. Das Konto und alles darin wird am {date} gelöscht. Bis dahin kannst du die Löschung hier rückgängig machen.", + "deleteUserBodyNow": "{name} und alles im Konto wird sofort gelöscht. Dieser Server hat keine Frist eingestellt, die Löschung lässt sich also nicht rückgängig machen.", + "deleteUserConfirm": "Konto löschen", + "deleteUserScheduled": "Das Konto {name} ist gesperrt und wird am {date} gelöscht", + "deleteUserDone": "Das Konto {name} wurde gelöscht", + "deleteUserFailed": "Das Konto konnte nicht gelöscht werden" }, "invites": { "title": "Einladungen", diff --git a/messages/en.json b/messages/en.json index fab5619d0..308ec10b3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8110,7 +8110,16 @@ "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" + "undoDeletionFailed": "Could not undo the deletion", + "undoDeletionBodyAdmin": "An admin deleted the account {name}, 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.", + "deleteUser": "Delete account", + "deleteUserTitle": "Delete the account {name}?", + "deleteUserBodyGrace": "{name} is signed out everywhere at once and can no longer sign in. The account and everything in it is erased on {date}. Until then you can undo the deletion here.", + "deleteUserBodyNow": "{name} and everything in the account is erased right away. This server keeps no grace window, so the deletion cannot be undone.", + "deleteUserConfirm": "Delete account", + "deleteUserScheduled": "The account {name} is locked and will be erased on {date}", + "deleteUserDone": "The account {name} was deleted", + "deleteUserFailed": "Could not delete the account" }, "invites": { "title": "Invites", diff --git a/messages/es.json b/messages/es.json index 1236f59ef..150c8db31 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8110,7 +8110,16 @@ "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" + "undoDeletionFailed": "No se pudo deshacer la eliminación", + "undoDeletionBodyAdmin": "Un administrador eliminó la cuenta {name}, 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.", + "deleteUser": "Eliminar cuenta", + "deleteUserTitle": "¿Eliminar la cuenta {name}?", + "deleteUserBodyGrace": "{name} cierra sesión en todas partes al instante y ya no puede iniciar sesión. La cuenta y todo su contenido se borran el {date}. Hasta entonces puedes deshacer la eliminación aquí.", + "deleteUserBodyNow": "{name} y todo el contenido de la cuenta se borran de inmediato. Este servidor no tiene plazo de gracia, así que la eliminación no se puede deshacer.", + "deleteUserConfirm": "Eliminar cuenta", + "deleteUserScheduled": "La cuenta {name} está bloqueada y se borrará el {date}", + "deleteUserDone": "Se eliminó la cuenta {name}", + "deleteUserFailed": "No se pudo eliminar la cuenta" }, "invites": { "title": "Invitaciones", diff --git a/messages/fr.json b/messages/fr.json index fe9d0a531..7665a38d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8110,7 +8110,16 @@ "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" + "undoDeletionFailed": "Impossible d’annuler la suppression", + "undoDeletionBodyAdmin": "Un administrateur a supprimé le compte {name}, 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.", + "deleteUser": "Supprimer le compte", + "deleteUserTitle": "Supprimer le compte {name} ?", + "deleteUserBodyGrace": "{name} est déconnecté partout immédiatement et ne peut plus se connecter. Le compte et tout son contenu seront effacés le {date}. D’ici là, vous pouvez annuler la suppression ici.", + "deleteUserBodyNow": "{name} et tout le contenu du compte sont effacés immédiatement. Ce serveur n’a pas de délai de grâce, la suppression ne peut donc pas être annulée.", + "deleteUserConfirm": "Supprimer le compte", + "deleteUserScheduled": "Le compte {name} est verrouillé et sera effacé le {date}", + "deleteUserDone": "Le compte {name} a été supprimé", + "deleteUserFailed": "Impossible de supprimer le compte" }, "invites": { "title": "Invitations", diff --git a/messages/it.json b/messages/it.json index 490af61d9..85988398b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8110,7 +8110,16 @@ "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" + "undoDeletionFailed": "Impossibile annullare l’eliminazione", + "undoDeletionBodyAdmin": "Un amministratore ha eliminato l’account {name}, 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.", + "deleteUser": "Elimina account", + "deleteUserTitle": "Eliminare l’account {name}?", + "deleteUserBodyGrace": "{name} viene disconnesso ovunque subito e non può più accedere. L’account e tutto il suo contenuto vengono cancellati il {date}. Fino ad allora puoi annullare l’eliminazione da qui.", + "deleteUserBodyNow": "{name} e tutto il contenuto dell’account vengono cancellati subito. Questo server non ha un periodo di tolleranza, quindi l’eliminazione non si può annullare.", + "deleteUserConfirm": "Elimina account", + "deleteUserScheduled": "L’account {name} è bloccato e verrà cancellato il {date}", + "deleteUserDone": "L’account {name} è stato eliminato", + "deleteUserFailed": "Impossibile eliminare l’account" }, "invites": { "title": "Inviti", diff --git a/messages/ko.json b/messages/ko.json index 1437b35ae..b46410178 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8085,7 +8085,16 @@ "undoDeletionBody": "{name} 님이 이 계정의 삭제를 요청했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.", "undoDeletionBodyGuardian": "보호자가 관리 프로필 {name}을(를) 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 프로필과 그 안의 모든 내용이 그대로 남고, 보호자들이 다시 접근할 수 있어요. 공유 링크와 토큰은 계속 취소된 상태라 다시 설정해야 해요.", "undoDeletionDone": "{name} 삭제를 취소했어요", - "undoDeletionFailed": "삭제를 취소하지 못했어요" + "undoDeletionFailed": "삭제를 취소하지 못했어요", + "undoDeletionBodyAdmin": "관리자가 {name} 계정을 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.", + "deleteUser": "계정 삭제", + "deleteUserTitle": "{name} 계정을 삭제할까요?", + "deleteUserBodyGrace": "{name} 님은 즉시 모든 곳에서 로그아웃되고 더 이상 로그인할 수 없어요. 계정과 그 안의 모든 내용은 {date}에 지워져요. 그때까지는 여기서 삭제를 취소할 수 있어요.", + "deleteUserBodyNow": "{name} 님과 계정 안의 모든 내용이 바로 지워져요. 이 서버에는 유예 기간이 없어서 삭제를 취소할 수 없어요.", + "deleteUserConfirm": "계정 삭제", + "deleteUserScheduled": "{name} 계정이 잠겼고 {date}에 지워져요", + "deleteUserDone": "{name} 계정을 삭제했어요", + "deleteUserFailed": "계정을 삭제하지 못했어요" }, "invites": { "title": "초대", diff --git a/messages/nl.json b/messages/nl.json index 6d62e60fc..968571fce 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8110,7 +8110,16 @@ "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" + "undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt", + "undoDeletionBodyAdmin": "Een beheerder heeft het account {name} verwijderd; 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.", + "deleteUser": "Account verwijderen", + "deleteUserTitle": "Het account {name} verwijderen?", + "deleteUserBodyGrace": "{name} wordt meteen overal uitgelogd en kan niet meer inloggen. Het account en alles erin wordt op {date} gewist. Tot dan kun je de verwijdering hier ongedaan maken.", + "deleteUserBodyNow": "{name} en alles in het account wordt meteen gewist. Deze server heeft geen bedenktijd, dus de verwijdering kan niet ongedaan worden gemaakt.", + "deleteUserConfirm": "Account verwijderen", + "deleteUserScheduled": "Het account {name} is vergrendeld en wordt op {date} gewist", + "deleteUserDone": "Het account {name} is verwijderd", + "deleteUserFailed": "Het account kon niet worden verwijderd" }, "invites": { "title": "Uitnodigingen", diff --git a/messages/pl.json b/messages/pl.json index 6dae52955..7681100be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8110,7 +8110,16 @@ "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" + "undoDeletionFailed": "Nie udało się cofnąć usunięcia", + "undoDeletionBodyAdmin": "Administrator usunął konto {name}; 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.", + "deleteUser": "Usuń konto", + "deleteUserTitle": "Usunąć konto {name}?", + "deleteUserBodyGrace": "{name} zostanie od razu wszędzie wylogowany i nie będzie mógł się już zalogować. Konto i cała jego zawartość zostaną usunięte {date}. Do tego czasu możesz tutaj cofnąć usunięcie.", + "deleteUserBodyNow": "{name} i cała zawartość konta zostaną usunięte od razu. Ten serwer nie ma okresu karencji, więc usunięcia nie da się cofnąć.", + "deleteUserConfirm": "Usuń konto", + "deleteUserScheduled": "Konto {name} jest zablokowane i zostanie usunięte {date}", + "deleteUserDone": "Konto {name} zostało usunięte", + "deleteUserFailed": "Nie udało się usunąć konta" }, "invites": { "title": "Zaproszenia", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index f7762533c..fedfb8d60 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8110,7 +8110,16 @@ "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" + "undoDeletionFailed": "Não foi possível desfazer a exclusão", + "undoDeletionBodyAdmin": "Um administrador excluiu a conta {name}, 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.", + "deleteUser": "Excluir conta", + "deleteUserTitle": "Excluir a conta {name}?", + "deleteUserBodyGrace": "{name} sai da conta em todos os lugares na hora e não consegue mais entrar. A conta e tudo o que há nela são apagados em {date}. Até lá, você pode desfazer a exclusão aqui.", + "deleteUserBodyNow": "{name} e tudo o que há na conta são apagados na hora. Este servidor não tem prazo de carência, então a exclusão não pode ser desfeita.", + "deleteUserConfirm": "Excluir conta", + "deleteUserScheduled": "A conta {name} está bloqueada e será apagada em {date}", + "deleteUserDone": "A conta {name} foi excluída", + "deleteUserFailed": "Não foi possível excluir a conta" }, "invites": { "title": "Convites", diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 7d647f30f..75f36fd43 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -679,6 +679,17 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["AlertDialog"], }, + { + file: "components/admin/delete-user-control.tsx", + destroys: + "another user's whole account, locked at once and erased when the deletion grace window ends (or at once when the window is 0)", + triggers: ["components/admin/user-management-section.tsx"], + // The undo route exists and the row offers it until the purge date + // (`pending-deletion-controls.tsx`); with the window at 0 there is + // nothing to undo, and the dialog says so. + recovery: "restorable", + confirm: ["ConfirmButton"], + }, // ── Integrations ──────────────────────────────────────────────────────── // Every connection renders the shared integration card, whose three-dot diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts index 5ef5aef9f..41be5b6b2 100644 --- a/src/__tests__/managed-profile-contract.test.ts +++ b/src/__tests__/managed-profile-contract.test.ts @@ -145,15 +145,27 @@ describe("managed-profile contract", () => { path.join(root, "src/lib/sharing/grants.ts"), "utf8", ); - const accountDeletion = await readFile( - path.join(root, "src/app/api/settings/account/route.ts"), + // Both account deletions (the account's own and an admin's) take the one + // request path, and that path decides the Guardian invariant. + const deletionRequest = await readFile( + path.join(root, "src/lib/account-deletion/request.ts"), "utf8", ); + const deletionRoutes = await Promise.all( + [ + "src/app/api/settings/account/route.ts", + "src/app/api/admin/users/[id]/route.ts", + ].map((rel) => readFile(path.join(root, rel), "utf8")), + ); expect(lifecycle).toContain("pg_advisory_xact_lock"); expect(lifecycle).toContain("clearManagedProfileMarker"); expect(lifecycle).toContain("deleteManagedProfile"); expect(grants).toContain("reduceManagedProfileGuardian"); - expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle"); + expect(deletionRequest).toContain("deleteGuardianAccountWithLifecycle("); + for (const route of deletionRoutes) { + expect(route).toContain("requestAccountDeletion("); + expect(route).not.toContain("lockAccountForDeletion("); + } }); }); diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index de9421d6e..bee50ca55 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -295,7 +295,7 @@ const UNPUBLISHED: Readonly> = { "/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/users": { kind: "adminConsole", methods: ["GET"] }, - "/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] }, + "/api/admin/users/{id}": { kind: "adminConsole", methods: ["DELETE", "PUT"] }, "/api/admin/users/{id}/force-logout": { kind: "adminConsole", methods: ["POST"], diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index f3833b347..4a5ac9287 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -111,6 +111,7 @@ const COOKIE_PROOF_ROUTES = [ "app/api/admin/backups/upload/route.ts", "app/api/admin/data/route.ts", "app/api/admin/users/[id]/reset-password/route.ts", + "app/api/admin/users/[id]/route.ts", "app/api/auth/reproof/route.ts", "app/api/tokens/documents/route.ts", "app/api/tokens/measurements/route.ts", diff --git a/src/__tests__/step-up-web-caller-guard.test.ts b/src/__tests__/step-up-web-caller-guard.test.ts index fbf260a0f..9dd1cd427 100644 --- a/src/__tests__/step-up-web-caller-guard.test.ts +++ b/src/__tests__/step-up-web-caller-guard.test.ts @@ -112,6 +112,7 @@ function discoverGatedRoutes(): string[] { */ const GATED_ROUTES: Record = { "DELETE /api/admin/data": "web", + "DELETE /api/admin/users/[id]": "web", "DELETE /api/auth/me/mfa/webauthn/[id]": "web", "DELETE /api/auth/passkeys/[id]": "web", "DELETE /api/managed-profiles/[id]": "web", diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 687edeaf2..f898e71a6 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -3,13 +3,21 @@ import { assertAnotherAdminStays, LastAdminError, } from "@/lib/auth/admin-roster"; -import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiHandler, + assertRecentCookieProof, + requireAdmin, +} from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; +import { requestAccountDeletion } from "@/lib/account-deletion/request"; +import { lockPendingAccount } from "@/lib/account-deletion/lifecycle"; +import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle"; +import { adminDeleteUserSchema } from "@/lib/validations/admin"; const updateUserSchema = z .object({ @@ -122,3 +130,134 @@ export const PUT = apiHandler( }); }, ); + +/** A refusal decided under the account's row lock, inside the transaction. */ +class DeletionRefusal extends Error { + constructor(readonly kind: "not_found" | "managed_profile" | "pending") { + super(`admin deletion refused: ${kind}`); + this.name = "DeletionRefusal"; + } +} + +function deletionRefusalResponse(kind: DeletionRefusal["kind"]): Response { + if (kind === "not_found") return apiError("User not found", 404); + if (kind === "managed_profile") { + return apiError("A managed profile is deleted by its Guardians", 409, { + errorCode: "admin.users.delete.managed_profile", + }); + } + return apiError("This account is already pending deletion", 409, { + errorCode: "account.deletion.already_pending", + }); +} + +/** + * DELETE /api/admin/users/{id} — an admin deletes another account. + * + * The same request the account's own deletion makes + * (`requestAccountDeletion`): with a grace window the account is locked at + * once (every credential revoked, sign-in refused) and erased by the purge job + * when the window ends, and `POST /api/admin/users/{id}/undo-deletion` gives it + * back until then; with `ACCOUNT_DELETION_GRACE_DAYS=0` it is erased here. + * + * Cookie-only (`requireAdmin`) behind a fresh proof (`assertRecentCookieProof`, + * the gate in front of the other admin acts on someone's data), with the typed + * confirmation the self-deletion asks for. Refused for the caller's own + * account (that is the account settings' deletion, with its own step-up), for + * the last admin who can sign in, for a managed profile (its Guardians delete + * it), for an account already pending deletion, and for the last Guardian of + * a managed profile. Audit-logged for the admin and on the account itself. + */ +export const DELETE = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const { user: admin, session } = await requireAdmin(); + await assertRecentCookieProof(admin, session.id); + + const { id } = await params; + annotate({ + action: { + name: "admin.users.delete", + entity_type: "user", + entity_id: id, + }, + }); + + const parsed = await parseJsonBody(request, adminDeleteUserSchema, { + maxBytes: 4 * 1024, + }); + if (!parsed.ok) return parsed.error; + + if (id === admin.id) { + return apiError( + "Delete your own account from your account settings", + 400, + { errorCode: "admin.users.delete.self" }, + ); + } + + const target = await prisma.user.findUnique({ + where: { id }, + select: { role: true, username: true }, + }); + if (!target) return apiError("User not found", 404); + + let purgeAt: Date | null; + try { + ({ purgeAt } = await requestAccountDeletion({ + userId: id, + targetIsAdmin: target.role === "ADMIN", + requestedById: admin.id, + inTransaction: async (tx, scheduledPurgeAt) => { + const state = await lockPendingAccount(tx, id); + if (!state) throw new DeletionRefusal("not_found"); + if (state.managedProfile) + throw new DeletionRefusal("managed_profile"); + if (state.pending) throw new DeletionRefusal("pending"); + const details = { + targetUserId: id, + username: target.username, + scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null, + }; + await auditLog("admin.user.delete", { + userId: admin.id, + ipAddress: getClientIp(request), + details, + client: tx, + }); + // On the account itself too, so its own activity shows who asked. + // An immediate erasure takes this row with the account's history. + await auditLog("user.account.delete", { + userId: id, + actorUserId: admin.id, + details: { ...details, requestedBy: "admin" }, + client: tx, + }); + }, + })); + } catch (error) { + if (error instanceof DeletionRefusal) { + return deletionRefusalResponse(error.kind); + } + if (error instanceof LastAdminError) { + return apiError("The last admin account cannot be deleted", 400); + } + if (error instanceof LastManagedGuardianError) { + return apiError( + "This account is the last Guardian of a managed profile. Add another Guardian or delete the profile first", + 409, + { errorCode: "managed_profile.guardian.required" }, + ); + } + throw error; + } + + annotate({ meta: { deletion_scheduled: purgeAt !== null } }); + return apiSuccess({ + deleted: true, + scheduledPurgeAt: purgeAt?.toISOString() ?? null, + }); + }, +); diff --git a/src/app/api/admin/users/__tests__/route.test.ts b/src/app/api/admin/users/__tests__/route.test.ts index c9ddc4bfd..38d25a864 100644 --- a/src/app/api/admin/users/__tests__/route.test.ts +++ b/src/app/api/admin/users/__tests__/route.test.ts @@ -100,6 +100,7 @@ describe("GET /api/admin/users", () => { // BigInt in Prisma, plain number on the wire. documentQuotaBytes: 2_147_483_648, passkeyCount: 2, + managedProfile: false, // Not pending deletion. deletion: null, }); @@ -128,6 +129,7 @@ describe("GET /api/admin/users", () => { deletionRequestedAt: true, deletionPurgeAt: true, deletionRequestedById: true, + managedProfileAt: true, _count: { select: { passkeys: true } }, }); expect(args.select).not.toHaveProperty("passwordHash"); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index a46dfd455..1fec01c53 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -21,6 +21,7 @@ export const GET = apiHandler(async () => { deletionRequestedAt: true, deletionPurgeAt: true, deletionRequestedById: true, + managedProfileAt: true, _count: { select: { passkeys: true } }, }, orderBy: { createdAt: "asc" }, @@ -42,9 +43,12 @@ export const GET = apiHandler(async () => { documentQuotaBytes: u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes), passkeyCount: u._count.passkeys, + // A record somebody else runs. Its Guardians delete it, so the list + // offers no admin deletion on it. + managedProfile: u.managedProfileAt != null, // An account pending deletion: when it was asked for, when the - // purge job erases it, and whether the account asked itself or a - // Guardian deleted the managed profile. Null on every other account. + // purge job erases it, and who asked: the account itself, a Guardian + // (a managed profile), or an admin. Null on every other account. // The list offers "Undo deletion" on these rows until `purgeAt`. deletion: u.deletionRequestedAt ? { @@ -53,7 +57,9 @@ export const GET = apiHandler(async () => { requestedBy: u.deletionRequestedById === u.id ? ("self" as const) - : ("guardian" as const), + : u.managedProfileAt != null + ? ("guardian" as const) + : ("admin" as const), } : null, })), diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index 2378f2aee..ead70b83d 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -8,24 +8,10 @@ import { MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { kickOffhostPurge } from "@/lib/jobs/offhost-purge"; -import { - deleteGuardianAccountWithLifecycle, - LastManagedGuardianError, -} from "@/lib/managed-profiles/lifecycle"; -import { - accountDeletionGraceDays, - accountDeletionPurgeAt, -} from "@/lib/account-deletion/grace"; -import { - eraseAccount, - lockAccountForDeletion, -} from "@/lib/account-deletion/lifecycle"; +import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle"; +import { requestAccountDeletion } from "@/lib/account-deletion/request"; import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; -import { - assertAnotherAdminStays, - LastAdminError, -} from "@/lib/auth/admin-roster"; +import { LastAdminError } from "@/lib/auth/admin-roster"; export const dynamic = "force-dynamic"; @@ -104,53 +90,27 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // the elevation would stay redeemable while the erasure was already running. await auth.commitElevation(); - const requestedAt = new Date(); - const purgeAt = accountDeletionPurgeAt( - requestedAt, - accountDeletionGraceDays(), - ); - + let purgeAt: Date | null; try { - await deleteGuardianAccountWithLifecycle( + ({ purgeAt } = await requestAccountDeletion({ userId, - async (tx) => { - // Checked again under the roster lock: two admins deleting at the - // same moment must not each count the other. - if (user.role === "ADMIN") await assertAnotherAdminStays(tx, userId); - // The invariant has now passed, so this success event cannot survive - // a refusal. On an immediate erasure it is purged with the rest of the - // account's audit history; during a grace window it is the account's - // own record of the request. - await auditLog("user.account.delete", { + targetIsAdmin: user.role === "ADMIN", + requestedById: userId, + // The invariants have passed by the time this runs, so this success + // event cannot survive a refusal. On an immediate erasure it is purged + // with the rest of the account's audit history; during a grace window + // it is the account's own record of the request. + inTransaction: (tx, scheduledPurgeAt) => + auditLog("user.account.delete", { userId, ipAddress: getClientIp(request), details: { username, - scheduledPurgeAt: purgeAt?.toISOString() ?? null, + scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null, }, client: tx, - }); - - if (purgeAt) { - // Locked, not erased: credentials revoked in this transaction, - // the record untouched until the purge job reaches `purgeAt`. - await lockAccountForDeletion(tx, { - userId, - requestedById: userId, - requestedAt, - purgeAt, - }); - return; - } - - // No window: erase now, inside the same record lifecycle transaction - // so a refusal leaves every account state intact (sessions, the - // account's audit history — GDPR Art. 17 — the off-host purge - // request, then the row with everything cascading from it). - await eraseAccount(tx, userId, "account_deleted"); - }, - { erasing: purgeAt === null }, - ); + }), + })); } catch (error) { if (error instanceof LastAdminError) { return apiError("The last admin account cannot be deleted", 400); @@ -165,8 +125,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => { throw error; } - if (!purgeAt) await kickOffhostPurge(); - annotate({ action: { name: "settings.account.delete" }, meta: { username, deletion_scheduled: purgeAt !== null }, diff --git a/src/components/admin/__tests__/user-management-responsive.test.tsx b/src/components/admin/__tests__/user-management-responsive.test.tsx index 60c5dedb9..0ee8df3be 100644 --- a/src/components/admin/__tests__/user-management-responsive.test.tsx +++ b/src/components/admin/__tests__/user-management-responsive.test.tsx @@ -30,6 +30,8 @@ const sampleUsers = [ role: "ADMIN" as const, passkeyCount: 2, createdAt: "2026-04-01T08:00:00Z", + managedProfile: false, + deletion: null, }, { id: "u2", @@ -38,6 +40,8 @@ const sampleUsers = [ role: "USER" as const, passkeyCount: 1, createdAt: "2026-04-15T08:00:00Z", + managedProfile: false, + deletion: null, }, ]; @@ -53,6 +57,10 @@ vi.mock("@tanstack/react-query", () => ({ }), })); +vi.mock("@/lib/queries/use-privacy-summary", () => ({ + useAccountDeletionGrace: () => ({ days: 14, pending: false }), +})); + vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { @@ -115,6 +123,15 @@ describe("UserManagementSection — mobile responsive", () => { expect(mobileSlice).toContain("Force logout"); }); + it("offers deleting every account but the admin's own", () => { + const html = render(); + // Two rows, each in the desktop table and the mobile list; only the + // other account carries the control. + const deletes = html.match(/data-slot="admin-user-delete"/g) ?? []; + expect(deletes).toHaveLength(2); + expect(html).toContain('aria-label="Delete account"'); + }); + it("uses smaller card padding on mobile (p-4 md:p-6)", () => { const html = render(); expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 2f2223653..6ea08b5a3 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -34,15 +34,17 @@ export interface AdminUser { // the instance-wide default applies. documentQuotaBytes: number | null; passkeyCount: number; + /** A record its Guardians run; they delete it, an admin does not. */ + managedProfile: boolean; /** - * Set while the account is pending deletion: when the purge job - * erases it, and whether the account asked itself or a Guardian deleted - * the managed profile. The row offers "Undo deletion" until then. + * Set while the account is pending deletion: when the purge job erases + * it, and who asked (the account itself, a Guardian of the managed + * profile, or an admin). The row offers "Undo deletion" until then. */ deletion: { requestedAt: string; purgeAt: string | null; - requestedBy: "self" | "guardian"; + requestedBy: "self" | "guardian" | "admin"; } | null; } diff --git a/src/components/admin/delete-user-control.tsx b/src/components/admin/delete-user-control.tsx new file mode 100644 index 000000000..dc25e1591 --- /dev/null +++ b/src/components/admin/delete-user-control.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; + +import { ConfirmButton } from "@/components/ui/confirm-button"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { apiDelete } from "@/lib/api/api-fetch"; +import { formatDate } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { useAccountDeletionGrace } from "@/lib/queries/use-privacy-summary"; +import { + recentProofErrorMessage, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; +import type { AdminUser } from "./_shared"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Whether the row offers deleting the account: never the admin's own (that is + * the account settings' deletion), never a managed profile (its Guardians + * delete it), and not one already pending deletion (the row offers the undo). + */ +export function canAdminDelete(user: AdminUser, currentUserId: string | null) { + return ( + user.id !== currentUserId && !user.managedProfile && user.deletion === null + ); +} + +/** + * The day the purge job would erase an account deleted now, or null when the + * window is 0 and the erasure happens at once. The server stamps the real + * date; this is the one the dialog names before asking. + */ +export function deletionDateFrom(graceDays: number, now: Date): Date | null { + return graceDays > 0 ? new Date(now.getTime() + graceDays * DAY_MS) : null; +} + +/** + * "Delete account" on a row of the admin users list + * (`DELETE /api/admin/users/{id}`), for the rows `canAdminDelete` allows. + */ +export function DeleteUserControl({ + user, + currentUserId, +}: { + user: AdminUser; + currentUserId: string | null; +}) { + if (!canAdminDelete(user, currentUserId)) return null; + return ; +} + +/** + * The dialog names the date the account is erased and that it can be undone + * here until then, or, with no grace window, that it is erased at once. It + * waits for the window before it offers anything, so the sentence is never a + * guess. The fresh proof the route asks for is collected by the shared + * re-proof dialog. + */ +function DeleteUserButton({ user }: { user: AdminUser }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const grace = useAccountDeletionGrace(); + const recentProof = useRecentProof(); + + const remove = useMutation({ + mutationFn: () => + recentProof.run(() => + apiDelete<{ scheduledPurgeAt: string | null }>( + `/api/admin/users/${user.id}`, + { confirm: "DELETE_ACCOUNT" }, + ), + ), + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() }); + toastWrittenOutcome( + "success", + result.scheduledPurgeAt + ? t("admin.section.users.deleteUserScheduled", { + name: user.username, + date: formatDate(result.scheduledPurgeAt), + }) + : t("admin.section.users.deleteUserDone", { name: user.username }), + ); + }, + onError: (err) => { + const message = recentProofErrorMessage( + err, + err instanceof Error && err.message + ? err.message + : t("admin.section.users.deleteUserFailed"), + ); + if (message) showErrorToast(message, { cause: err }); + }, + }); + + const purgeOn = + grace.days === null ? null : deletionDateFrom(grace.days, new Date()); + + return ( + <> + {recentProof.dialog} +