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:
Marc-André Bombeck committed 2026-10-29 09:10:26 +01:00
1 parent ef141f457b
commit 107db1bb55
28 files changed
+828 -95

No files matched your search

@@ -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
+15 -3
View File
@@ -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",