feat(user): add soft delete support to deleteUser

Add a `softDelete` option that sets a `deletedAt` timestamp instead of
permanently removing the user. Soft-deleted users are filtered from
authentication lookups but can be hard-deleted by admins.
This commit is contained in:
quochuydev
2026-02-12 18:04:19 +07:00
parent 86ca6d0b19
commit 3eaf3c540c
8 changed files with 298 additions and 7 deletions
@@ -573,6 +573,212 @@ describe("delete user", async () => {
});
});
it("should soft delete user with deletedAt when softDelete is enabled", async () => {
const { client, signInWithTestUser, db } = await getTestInstance({
user: {
deleteUser: {
enabled: true,
softDelete: true,
},
},
session: {
freshAge: 1000,
},
});
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
const session = await client.getSession();
const userId = session.data!.user.id;
const res = await client.deleteUser({
softDelete: true,
});
expect(res.data).toMatchObject({
success: true,
});
// User should still exist in DB with deletedAt set
const dbUser = await db.findOne<{ deletedAt: Date | null }>({
model: "user",
where: [{ field: "id", value: userId }],
});
expect(dbUser).toBeDefined();
expect(dbUser!.deletedAt).toBeDefined();
expect(dbUser!.deletedAt).not.toBeNull();
// Session should be revoked
const sessionAfter = await client.getSession();
expect(sessionAfter.data).toBeNull();
});
});
it("should prevent soft-deleted user from signing in", async () => {
const { client, signInWithTestUser, testUser } = await getTestInstance({
user: {
deleteUser: {
enabled: true,
softDelete: true,
},
},
session: {
freshAge: 1000,
},
});
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
await client.deleteUser({
softDelete: true,
});
});
// Signing in should fail because user is soft-deleted
const signInRes = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(signInRes.error).toBeDefined();
});
it("should still hard delete by default even with softDelete config enabled", async () => {
const { client, signInWithTestUser, db } = await getTestInstance({
user: {
deleteUser: {
enabled: true,
softDelete: true,
},
},
session: {
freshAge: 1000,
},
});
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
const session = await client.getSession();
const userId = session.data!.user.id;
const res = await client.deleteUser();
expect(res.data).toMatchObject({
success: true,
});
// User should be completely gone from DB
const dbUser = await db.findOne({
model: "user",
where: [{ field: "id", value: userId }],
});
expect(dbUser).toBeNull();
});
});
it("should hard delete when body has softDelete but config does not", async () => {
const { client, signInWithTestUser, db } = await getTestInstance({
user: {
deleteUser: {
enabled: true,
},
},
session: {
freshAge: 1000,
},
});
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
const session = await client.getSession();
const userId = session.data!.user.id;
const res = await client.deleteUser({
softDelete: true,
});
expect(res.data).toMatchObject({
success: true,
});
// User should be hard deleted since config.softDelete is off
const dbUser = await db.findOne({
model: "user",
where: [{ field: "id", value: userId }],
});
expect(dbUser).toBeNull();
});
});
it("should allow admin to hard-delete a soft-deleted user", async () => {
const { admin } = await import("../../plugins/admin/admin");
const { adminClient: adminClientPlugin } = await import(
"../../plugins/admin/client"
);
const { client, signInWithTestUser, db, sessionSetter } =
await getTestInstance(
{
user: {
deleteUser: {
enabled: true,
softDelete: true,
},
},
plugins: [admin()],
session: {
freshAge: 1000,
},
},
{
clientOptions: {
plugins: [adminClientPlugin()],
},
},
);
// Sign up and soft-delete a user
const { runWithUser } = await signInWithTestUser();
let userId = "";
await runWithUser(async () => {
const session = await client.getSession();
userId = session.data!.user.id;
await client.deleteUser({
softDelete: true,
});
});
// Verify user is soft-deleted in DB
const dbUser = await db.findOne<{ deletedAt: Date | null }>({
model: "user",
where: [{ field: "id", value: userId }],
});
expect(dbUser).toBeDefined();
expect(dbUser!.deletedAt).not.toBeNull();
// Create an admin user
const adminHeaders = new Headers();
await client.signUp.email({
email: "admin@test.com",
password: "admin-password",
name: "Admin",
fetchOptions: {
onSuccess: sessionSetter(adminHeaders),
},
});
// Set admin role directly
await db.update({
model: "user",
update: { role: "admin" },
where: [{ field: "email", value: "admin@test.com" }],
});
// Admin hard-deletes the soft-deleted user
const removeRes = await client.admin.removeUser(
{ userId },
{ headers: adminHeaders },
);
expect(removeRes.data?.success).toBe(true);
// User should now be completely gone
const dbUserAfter = await db.findOne({
model: "user",
where: [{ field: "id", value: userId }],
});
expect(dbUserAfter).toBeNull();
});
it("should ignore cookie cache for sensitive operations like changePassword", async () => {
const { client: cacheClient, sessionSetter: cacheSessionSetter } =
await getTestInstance(
@@ -401,6 +401,17 @@ export const deleteUser = createAuthEndpoint(
description: "The token to delete the user is required",
})
.optional(),
/**
* If true and soft delete is enabled in config, the user will be
* soft-deleted (deletedAt timestamp set) instead of permanently removed.
*/
softDelete: z
.boolean()
.meta({
description:
"Soft delete the user instead of permanently removing them",
})
.optional(),
}),
metadata: {
openapi: {
@@ -426,6 +437,11 @@ export const deleteUser = createAuthEndpoint(
type: "string",
description: "The deletion verification token",
},
softDelete: {
type: "boolean",
description:
"Soft delete the user instead of permanently removing them",
},
},
},
},
@@ -549,7 +565,16 @@ export const deleteUser = createAuthEndpoint(
if (beforeDelete) {
await beforeDelete(session.user, ctx.request);
}
await ctx.context.internalAdapter.deleteUser(session.user.id);
const isSoftDelete =
ctx.body.softDelete === true &&
ctx.context.options.user?.deleteUser?.softDelete === true;
if (isSoftDelete) {
await ctx.context.internalAdapter.updateUser(session.user.id, {
deletedAt: new Date(),
});
} else {
await ctx.context.internalAdapter.deleteUser(session.user.id);
}
await ctx.context.internalAdapter.deleteSessions(session.user.id);
deleteSessionCookie(ctx);
const afterDelete = ctx.context.options.user.deleteUser?.afterDelete;
@@ -640,9 +665,17 @@ export const deleteUserCallback = createAuthEndpoint(
if (beforeDelete) {
await beforeDelete(session.user, ctx.request);
}
await ctx.context.internalAdapter.deleteUser(session.user.id);
const isSoftDelete =
ctx.context.options.user?.deleteUser?.softDelete === true;
if (isSoftDelete) {
await ctx.context.internalAdapter.updateUser(session.user.id, {
deletedAt: new Date(),
});
} else {
await ctx.context.internalAdapter.deleteUser(session.user.id);
await ctx.context.internalAdapter.deleteAccounts(session.user.id);
}
await ctx.context.internalAdapter.deleteSessions(session.user.id);
await ctx.context.internalAdapter.deleteAccounts(session.user.id);
await ctx.context.internalAdapter.deleteVerificationValue(token.id);
deleteSessionCookie(ctx);
@@ -43,6 +43,7 @@ export const createInternalAdapter = (
): InternalAdapter => {
const logger = ctx.logger;
const options = ctx.options;
const softDeleteEnabled = options.user?.deleteUser?.softDelete === true;
const secondaryStorage = options.secondaryStorage;
const sessionExpiration = options.session?.expiresIn || 60 * 60 * 24 * 7; // 7 days
const {
@@ -760,8 +761,13 @@ export const createInternalAdapter = (
user: true,
},
});
const isSoftDeleted = (u: Record<string, unknown>) =>
softDeleteEnabled && u?.deletedAt != null;
if (account) {
if (account.user) {
if (isSoftDeleted(account.user)) {
return null;
}
return {
user: account.user,
linkedAccount: account,
@@ -777,7 +783,7 @@ export const createInternalAdapter = (
},
],
});
if (user) {
if (user && !isSoftDeleted(user)) {
return {
user,
linkedAccount: account,
@@ -796,7 +802,7 @@ export const createInternalAdapter = (
},
],
});
if (user) {
if (user && !isSoftDeleted(user)) {
const accounts = await (
await getCurrentAdapter(adapter)
).findMany<Account>({
@@ -837,6 +843,13 @@ export const createInternalAdapter = (
...(options?.includeAccounts ? { account: true } : {}),
},
});
if (
result &&
softDeleteEnabled &&
(result as Record<string, unknown>).deletedAt != null
) {
return null;
}
if (!result) return null;
const { account: accounts, ...user } = result;
return {
@@ -844,7 +857,10 @@ export const createInternalAdapter = (
accounts: accounts ?? [],
};
},
findUserById: async (userId: string) => {
findUserById: async (
userId: string,
opts?: { includeSoftDeleted?: boolean },
) => {
if (!userId) return null;
const user = await (await getCurrentAdapter(adapter)).findOne<User>({
model: "user",
@@ -855,6 +871,14 @@ export const createInternalAdapter = (
},
],
});
if (
user &&
softDeleteEnabled &&
!opts?.includeSoftDeleted &&
(user as Record<string, unknown>).deletedAt != null
) {
return null;
}
return user;
},
linkAccount: async (
@@ -1443,6 +1443,7 @@ export const removeUser = (opts: AdminOptions) =>
const user = await ctx.context.internalAdapter.findUserById(
ctx.body.userId,
{ includeSoftDeleted: true },
);
if (!user) {
@@ -1010,6 +1010,10 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"description": "The user's password. Required if session is not fresh",
"type": "string",
},
"softDelete": {
"description": "Soft delete the user instead of permanently removing them",
"type": "boolean",
},
"token": {
"description": "The deletion verification token",
"type": "string",
+11
View File
@@ -192,6 +192,17 @@ export const getAuthTables = (
required: true,
fieldName: options.user?.fields?.updatedAt || "updatedAt",
},
...(options.user?.deleteUser?.softDelete
? {
deletedAt: {
type: "date" as const,
required: false,
input: false,
returned: true,
fieldName: "deletedAt",
},
}
: {}),
...user?.fields,
...options.user?.additionalFields,
},
+4 -1
View File
@@ -160,7 +160,10 @@ export interface InternalAdapter<
options?: { includeAccounts: boolean } | undefined,
): Promise<{ user: User; accounts: Account[] } | null>;
findUserById(userId: string): Promise<User | null>;
findUserById(
userId: string,
options?: { includeSoftDeleted?: boolean },
): Promise<User | null>;
linkAccount(
account: Omit<Account, "id" | "createdAt" | "updatedAt"> & Partial<Account>,
+9
View File
@@ -679,6 +679,15 @@ export type BetterAuthOptions = {
* Enable user deletion
*/
enabled?: boolean;
/**
* Enable soft delete. When enabled, calling `deleteUser` with
* `softDelete: true` will set a `deletedAt` timestamp instead
* of permanently removing the user. A `deletedAt` column is
* added to the user table when this option is `true`.
*
* Soft-deleted users cannot authenticate.
*/
softDelete?: boolean;
/**
* Send a verification email when the user deletes their account.
*