mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
82 lines
3.1 KiB
TypeScript
82 lines
3.1 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|