mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-28 14:45:40 -05:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user