mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
ef141f457b
commit
107db1bb55
28 files changed
+828
-95
No files matched your search
@@ -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;
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
+10
-1
@@ -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": "초대",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -295,7 +295,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,6 +112,7 @@ function discoverGatedRoutes(): string[] {
|
||||
*/
|
||||
const GATED_ROUTES: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <DeleteUserButton user={user} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
<ConfirmButton
|
||||
slot="admin-user-delete"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
|
||||
icon={<Trash2 className="h-3 w-3" aria-hidden="true" />}
|
||||
label=""
|
||||
ariaLabel={t("admin.section.users.deleteUser")}
|
||||
disabled={grace.days === null}
|
||||
title={t("admin.section.users.deleteUserTitle", {
|
||||
name: user.username,
|
||||
})}
|
||||
body={
|
||||
purgeOn
|
||||
? t("admin.section.users.deleteUserBodyGrace", {
|
||||
name: user.username,
|
||||
date: formatDate(purgeOn),
|
||||
})
|
||||
: t("admin.section.users.deleteUserBodyNow", {
|
||||
name: user.username,
|
||||
})
|
||||
}
|
||||
confirmLabel={t("admin.section.users.deleteUserConfirm")}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
destructive
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,13 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { AdminUser } from "./_shared";
|
||||
|
||||
/** The undo dialog's sentence, by who asked for the deletion. */
|
||||
const UNDO_BODY_KEYS = {
|
||||
self: "admin.section.users.undoDeletionBody",
|
||||
guardian: "admin.section.users.undoDeletionBodyGuardian",
|
||||
admin: "admin.section.users.undoDeletionBodyAdmin",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* An account pending deletion, in the admin users list: the date the purge
|
||||
* job erases it, and the one action that stops it.
|
||||
@@ -64,12 +71,9 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
|
||||
title={t("admin.section.users.undoDeletionTitle", {
|
||||
name: user.username,
|
||||
})}
|
||||
body={t(
|
||||
user.deletion.requestedBy === "guardian"
|
||||
? "admin.section.users.undoDeletionBodyGuardian"
|
||||
: "admin.section.users.undoDeletionBody",
|
||||
{ name: user.username },
|
||||
)}
|
||||
body={t(UNDO_BODY_KEYS[user.deletion.requestedBy], {
|
||||
name: user.username,
|
||||
})}
|
||||
confirmLabel={t("admin.section.users.undoDeletion")}
|
||||
pending={undo.isPending}
|
||||
onConfirm={() => undo.mutate()}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
import { PendingDeletionControls } from "./pending-deletion-controls";
|
||||
import { DeleteUserControl } from "./delete-user-control";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
@@ -55,6 +56,13 @@ import {
|
||||
*/
|
||||
type UserFilter = "all" | "admin" | "user";
|
||||
|
||||
/** The quota override as the edit form's gigabyte field shows it. */
|
||||
function quotaGbInput(bytes: number | null): string {
|
||||
if (bytes === null) return "";
|
||||
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
|
||||
return String(Math.round((bytes / 1_073_741_824) * 10) / 10);
|
||||
}
|
||||
|
||||
export function UserManagementSection() {
|
||||
const { t } = useTranslations();
|
||||
const currentUserId = useAuth().user?.id ?? "";
|
||||
@@ -160,12 +168,7 @@ export function UserManagementSection() {
|
||||
setEditingUser(u);
|
||||
setEditUsername(u.username);
|
||||
setEditEmail(u.email ?? "");
|
||||
setEditQuotaGb(
|
||||
u.documentQuotaBytes === null
|
||||
? ""
|
||||
: // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
|
||||
String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
|
||||
);
|
||||
setEditQuotaGb(quotaGbInput(u.documentQuotaBytes));
|
||||
}
|
||||
|
||||
function startReset(u: AdminUser) {
|
||||
@@ -229,7 +232,7 @@ export function UserManagementSection() {
|
||||
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
|
||||
<span className="sr-only">, {u.username}</span>
|
||||
</Button>
|
||||
{/* The three icon tools travel as one group: on a phone the row wraps
|
||||
{/* The icon tools travel as one group: on a phone the row wraps
|
||||
between the labelled toggles and the tools, never leaving the
|
||||
sign-out icon alone on a line. */}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
@@ -272,6 +275,7 @@ export function UserManagementSection() {
|
||||
>
|
||||
<LogOut className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<DeleteUserControl user={u} currentUserId={currentUserId} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Asking for an account to be deleted: the one path the account's own
|
||||
* deletion (`DELETE /api/settings/account`) and an admin's deletion of
|
||||
* another account (`DELETE /api/admin/users/{id}`) both take.
|
||||
*
|
||||
* With a grace window (`ACCOUNT_DELETION_GRACE_DAYS`) the account is locked:
|
||||
* every credential is revoked at once, the record stays exactly as it is, and
|
||||
* the purge job erases it when the window ends; an admin can undo it until
|
||||
* then. With the window at 0 it is erased here, in the same transaction.
|
||||
*
|
||||
* The Guardian invariant (an account may not leave a managed profile without
|
||||
* a Guardian) and the admin roster (the last admin who can sign in stays) are
|
||||
* decided inside the transaction, under their locks, and refuse by throwing
|
||||
* `LastManagedGuardianError` or `LastAdminError`.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
|
||||
import { deleteGuardianAccountWithLifecycle } from "@/lib/managed-profiles/lifecycle";
|
||||
import { assertAnotherAdminStays } from "@/lib/auth/admin-roster";
|
||||
import {
|
||||
accountDeletionGraceDays,
|
||||
accountDeletionPurgeAt,
|
||||
} from "@/lib/account-deletion/grace";
|
||||
import {
|
||||
eraseAccount,
|
||||
lockAccountForDeletion,
|
||||
} from "@/lib/account-deletion/lifecycle";
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
export async function requestAccountDeletion(input: {
|
||||
userId: string;
|
||||
/** Whether the account being deleted is an admin (the roster check). */
|
||||
targetIsAdmin: boolean;
|
||||
/** Who asked: the account itself, or the admin acting on it. */
|
||||
requestedById: string;
|
||||
/**
|
||||
* Runs first inside the transaction, before anything is written: the place
|
||||
* for a refusal that must be decided under the same locks (throw), and for
|
||||
* the audit rows, which then cannot outlive a refusal.
|
||||
*/
|
||||
inTransaction: (tx: Tx, purgeAt: Date | null) => Promise<void>;
|
||||
}): Promise<{ purgeAt: Date | null }> {
|
||||
const requestedAt = new Date();
|
||||
const purgeAt = accountDeletionPurgeAt(
|
||||
requestedAt,
|
||||
accountDeletionGraceDays(),
|
||||
);
|
||||
|
||||
await deleteGuardianAccountWithLifecycle(
|
||||
input.userId,
|
||||
async (tx) => {
|
||||
// Checked again under the roster lock: two admins deleting at the same
|
||||
// moment must not each count the other.
|
||||
if (input.targetIsAdmin) await assertAnotherAdminStays(tx, input.userId);
|
||||
await input.inTransaction(tx, purgeAt);
|
||||
|
||||
if (purgeAt) {
|
||||
// Locked, not erased: credentials revoked in this transaction, the
|
||||
// record untouched until the purge job reaches `purgeAt`.
|
||||
await lockAccountForDeletion(tx, {
|
||||
userId: input.userId,
|
||||
requestedById: input.requestedById,
|
||||
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, input.userId, "account_deleted");
|
||||
},
|
||||
{ erasing: purgeAt === null },
|
||||
);
|
||||
|
||||
if (!purgeAt) await kickOffhostPurge();
|
||||
return { purgeAt };
|
||||
}
|
||||
@@ -40,8 +40,13 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
|
||||
*/
|
||||
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
{
|
||||
account: ["account.deletion.no_guardian", "account.deletion.not_pending"],
|
||||
account: [
|
||||
"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",
|
||||
|
||||
@@ -106,3 +106,11 @@ export const adminSettingsSchema = z
|
||||
assistantDocumentAiEnabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Body of DELETE /api/admin/users/{id}: the typed confirmation the account's
|
||||
* own deletion asks for, so a stray request cannot lock an account.
|
||||
*/
|
||||
export const adminDeleteUserSchema = z
|
||||
.object({ confirm: z.literal("DELETE_ACCOUNT") })
|
||||
.strict();
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* An admin deletes another account, against real Postgres.
|
||||
*
|
||||
* `DELETE /api/admin/users/{id}` takes the account's own deletion path: with
|
||||
* a grace window the account is locked at once (credentials revoked, record
|
||||
* untouched) and the admin undo gives it back until the purge date; with the
|
||||
* window at 0 it is erased in the request. Mutations that must turn this red:
|
||||
* - erase instead of lock (the row is gone after the request);
|
||||
* - lock without revoking (the target's session survives);
|
||||
* - drop the fresh-proof gate (an old admin session deletes);
|
||||
* - drop the self, managed-profile or already-pending refusals.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
async function seedUser(id: string, role = "USER", managed = false) {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
role,
|
||||
onboardingCompletedAt: new Date(),
|
||||
...(managed ? { managedProfileAt: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(Date.UTC(2026, 8, 1, 7)),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Sign the admin in; `createdAgoMs` ages the session past the proof window. */
|
||||
async function signInAdmin(createdAgoMs = 0) {
|
||||
const now = Date.now();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: "admin-1",
|
||||
createdAt: new Date(now - createdAgoMs),
|
||||
expiresAt: new Date(now + HOUR_MS),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function deleteUser(
|
||||
id: string,
|
||||
body: unknown = { confirm: "DELETE_ACCOUNT" },
|
||||
) {
|
||||
const { DELETE } = await import("@/app/api/admin/users/[id]/route");
|
||||
return (DELETE as unknown as Handler)(
|
||||
new NextRequest(`http://localhost/api/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function errorCode(res: Response) {
|
||||
return ((await res.json()) as { meta?: { errorCode?: string } }).meta
|
||||
?.errorCode;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.rateLimit.deleteMany();
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "14");
|
||||
await seedUser("admin-1", "ADMIN");
|
||||
await seedUser("target-1");
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: "target-1",
|
||||
expiresAt: new Date(Date.now() + HOUR_MS),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("DELETE /api/admin/users/{id}", () => {
|
||||
it("locks the account for the grace window, revokes it and keeps the record", async () => {
|
||||
await signInAdmin();
|
||||
const before = Date.now();
|
||||
const res = await deleteUser("target-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { deleted: boolean; scheduledPurgeAt: string | null };
|
||||
};
|
||||
expect(body.data.deleted).toBe(true);
|
||||
const purgeAt = new Date(body.data.scheduledPurgeAt!).getTime();
|
||||
expect(purgeAt).toBeGreaterThanOrEqual(before + 14 * DAY_MS - 1000);
|
||||
expect(purgeAt).toBeLessThanOrEqual(Date.now() + 14 * DAY_MS + 1000);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const target = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: "target-1" },
|
||||
select: { deletionRequestedAt: true, deletionRequestedById: true },
|
||||
});
|
||||
expect(target.deletionRequestedAt).not.toBeNull();
|
||||
expect(target.deletionRequestedById).toBe("admin-1");
|
||||
expect(await prisma.session.count({ where: { userId: "target-1" } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await prisma.measurement.count({ where: { userId: "target-1" } }),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { userId: "admin-1", action: "admin.user.delete" },
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { userId: "target-1", action: "user.account.delete" },
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
// The list names the admin as the one who asked, and the undo gives the
|
||||
// account back.
|
||||
const list = await import("@/app/api/admin/users/route");
|
||||
const listed = (await (await list.GET()).json()) as {
|
||||
data: Array<{ id: string; deletion: { requestedBy: string } | null }>;
|
||||
};
|
||||
expect(
|
||||
listed.data.find((u) => u.id === "target-1")?.deletion?.requestedBy,
|
||||
).toBe("admin");
|
||||
|
||||
const undo = await import("@/app/api/admin/users/[id]/undo-deletion/route");
|
||||
const undone = await (undo.POST as unknown as Handler)(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/users/target-1/undo-deletion",
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ id: "target-1" }) },
|
||||
);
|
||||
expect(undone.status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await prisma.user.findUniqueOrThrow({
|
||||
where: { id: "target-1" },
|
||||
select: { deletionRequestedAt: true },
|
||||
})
|
||||
).deletionRequestedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("erases at once when the server keeps no grace window", async () => {
|
||||
vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "0");
|
||||
await signInAdmin();
|
||||
const res = await deleteUser("target-1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
((await res.json()) as { data: { scheduledPurgeAt: string | null } }).data
|
||||
.scheduledPurgeAt,
|
||||
).toBeNull();
|
||||
expect(
|
||||
await getPrismaClient().user.count({ where: { id: "target-1" } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("asks for a fresh proof from an old admin session", async () => {
|
||||
await signInAdmin(HOUR_MS);
|
||||
const res = await deleteUser("target-1");
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.required");
|
||||
expect(
|
||||
(
|
||||
await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: "target-1" },
|
||||
select: { deletionRequestedAt: true },
|
||||
})
|
||||
).deletionRequestedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses the admin's own account", async () => {
|
||||
await signInAdmin();
|
||||
const res = await deleteUser("admin-1");
|
||||
expect(res.status).toBe(400);
|
||||
expect(await errorCode(res)).toBe("admin.users.delete.self");
|
||||
});
|
||||
|
||||
it("refuses without the typed confirmation", async () => {
|
||||
await signInAdmin();
|
||||
const res = await deleteUser("target-1", {});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("refuses a managed profile and an account already pending deletion", async () => {
|
||||
await seedUser("profile-1", "USER", true);
|
||||
await signInAdmin();
|
||||
const managed = await deleteUser("profile-1");
|
||||
expect(managed.status).toBe(409);
|
||||
expect(await errorCode(managed)).toBe("admin.users.delete.managed_profile");
|
||||
|
||||
expect((await deleteUser("target-1")).status).toBe(200);
|
||||
const again = await deleteUser("target-1");
|
||||
expect(again.status).toBe(409);
|
||||
expect(await errorCode(again)).toBe("account.deletion.already_pending");
|
||||
});
|
||||
|
||||
it("refuses a caller who is not an admin", async () => {
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: "admin-1" },
|
||||
data: { role: "USER" },
|
||||
});
|
||||
await signInAdmin();
|
||||
await expect(deleteUser("target-1")).resolves.toHaveProperty("status", 403);
|
||||
});
|
||||
|
||||
it("deletes another admin while the caller stays", async () => {
|
||||
await seedUser("admin-2", "ADMIN");
|
||||
await signInAdmin();
|
||||
expect((await deleteUser("admin-2")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user