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

@@ -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/);
+6 -4
View File
@@ -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>
</>
);