diff --git a/docs/content/docs/concepts/database.mdx b/docs/content/docs/concepts/database.mdx index 7288f42f1e..6d7b42e115 100644 --- a/docs/content/docs/concepts/database.mdx +++ b/docs/content/docs/concepts/database.mdx @@ -501,7 +501,7 @@ There are two types of hooks you can define: #### 1. Before Hook -- **Purpose**: This hook is called before the respective entity (user, session, or account) is created or updated. +- **Purpose**: This hook is called before the respective entity (user, session, or account) is created, updated, or deleted. - **Behavior**: If the hook returns `false`, the operation will be aborted. And If it returns a data object, it'll replace the original payload. #### 2. After Hook @@ -532,6 +532,33 @@ export const auth = betterAuth({ //perform additional actions, like creating a stripe customer }, }, + delete: { + before: async (user, ctx) => { + console.log(`User ${user.email} is being deleted`); + if (user.email.includes("admin")) { + return false; // Abort deletion + } + + return true; // Allow deletion + }, + after: async (user) => { + console.log(`User ${user.email} has been deleted`); + }, + }, + }, + session: { + delete: { + before: async (session, ctx) => { + console.log(`Session ${session.token} is being deleted`); + if (session.userId === "admin-user-id") { + return false; // Abort deletion + } + return true; // Allow deletion + }, + after: async (session) => { + console.log(`Session ${session.token} has been deleted`); + }, + }, }, }, }); diff --git a/packages/better-auth/src/api/routes/update-user.ts b/packages/better-auth/src/api/routes/update-user.ts index 1aae548dd0..a32d1921e2 100644 --- a/packages/better-auth/src/api/routes/update-user.ts +++ b/packages/better-auth/src/api/routes/update-user.ts @@ -519,8 +519,8 @@ export const deleteUser = createAuthEndpoint( if (beforeDelete) { await beforeDelete(session.user, ctx.request); } - await ctx.context.internalAdapter.deleteUser(session.user.id); - await ctx.context.internalAdapter.deleteSessions(session.user.id); + await ctx.context.internalAdapter.deleteUser(session.user.id, ctx); + await ctx.context.internalAdapter.deleteSessions(session.user.id, ctx); await ctx.context.internalAdapter.deleteAccounts(session.user.id); deleteSessionCookie(ctx); const afterDelete = ctx.context.options.user.deleteUser?.afterDelete; @@ -607,10 +607,10 @@ export const deleteUserCallback = createAuthEndpoint( if (beforeDelete) { await beforeDelete(session.user, ctx.request); } - await ctx.context.internalAdapter.deleteUser(session.user.id); - await ctx.context.internalAdapter.deleteSessions(session.user.id); + await ctx.context.internalAdapter.deleteUser(session.user.id, ctx); + await ctx.context.internalAdapter.deleteSessions(session.user.id, ctx); await ctx.context.internalAdapter.deleteAccounts(session.user.id); - await ctx.context.internalAdapter.deleteVerificationValue(token.id); + await ctx.context.internalAdapter.deleteVerificationValue(token.id, ctx); deleteSessionCookie(ctx); diff --git a/packages/better-auth/src/db/db.test.ts b/packages/better-auth/src/db/db.test.ts index f125f70607..fed4c4e9df 100644 --- a/packages/better-auth/src/db/db.test.ts +++ b/packages/better-auth/src/db/db.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getTestInstance } from "../test-utils/test-instance"; describe("db", async () => { @@ -96,4 +96,147 @@ describe("db", async () => { }); expect(session?.user.email).toBe("test@email.com"); }); + + it("delete hooks", async () => { + const hookUserDeleteBefore = vi.fn(); + const hookUserDeleteAfter = vi.fn(); + const hookSessionDeleteBefore = vi.fn(); + const hookSessionDeleteAfter = vi.fn(); + + const { client } = await getTestInstance({ + session: { + storeSessionInDatabase: true, + }, + user: { + deleteUser: { + enabled: true, + }, + }, + databaseHooks: { + user: { + delete: { + async before(user, context) { + hookUserDeleteBefore(user, context); + return true; + }, + async after(user, context) { + hookUserDeleteAfter(user, context); + }, + }, + }, + session: { + delete: { + async before(session, context) { + hookSessionDeleteBefore(session, context); + return true; + }, + async after(session, context) { + hookSessionDeleteAfter(session, context); + }, + }, + }, + }, + }); + + const res = await client.signUp.email({ + email: "delete-test@email.com", + password: "password", + name: "Delete Test User", + }); + + expect(res.data).toBeDefined(); + const userId = res.data?.user.id; + + await client.deleteUser({ + fetchOptions: { + headers: { + Authorization: `Bearer ${res.data?.token}`, + }, + throw: true, + }, + }); + + expect(hookUserDeleteBefore).toHaveBeenCalledOnce(); + expect(hookUserDeleteAfter).toHaveBeenCalledOnce(); + expect(hookSessionDeleteBefore).toHaveBeenCalledOnce(); + expect(hookSessionDeleteAfter).toHaveBeenCalledOnce(); + + expect(hookUserDeleteBefore).toHaveBeenCalledWith( + expect.objectContaining({ + id: userId, + email: "delete-test@email.com", + name: "Delete Test User", + }), + expect.any(Object), + ); + + expect(hookUserDeleteAfter).toHaveBeenCalledWith( + expect.objectContaining({ + id: userId, + email: "delete-test@email.com", + name: "Delete Test User", + }), + expect.any(Object), + ); + }); + + it("delete hooks abort", async () => { + const hookUserDeleteBefore = vi.fn(); + const hookUserDeleteAfter = vi.fn(); + + const { client } = await getTestInstance({ + user: { + deleteUser: { + enabled: true, + }, + }, + databaseHooks: { + user: { + delete: { + async before(user, context) { + hookUserDeleteBefore(user, context); + return false; + }, + async after(user, context) { + hookUserDeleteAfter(user, context); + }, + }, + }, + }, + }); + + const res = await client.signUp.email({ + email: "abort-delete-test@email.com", + password: "password", + name: "Abort Delete Test User", + }); + + expect(res.data).toBeDefined(); + const userId = res.data?.user.id; + + try { + await client.deleteUser({ + fetchOptions: { + headers: { + Authorization: `Bearer ${res.data?.token}`, + }, + throw: true, + }, + }); + } catch (error) { + // Expected to fail due to hook returning false + } + + expect(hookUserDeleteBefore).toHaveBeenCalledOnce(); + expect(hookUserDeleteAfter).not.toHaveBeenCalled(); + + expect(hookUserDeleteBefore).toHaveBeenCalledWith( + expect.objectContaining({ + id: userId, + email: "abort-delete-test@email.com", + name: "Abort Delete Test User", + }), + expect.any(Object), + ); + }); }); diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index 75c79d76a8..8ab1c524c6 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -32,8 +32,13 @@ export const createInternalAdapter = ( const options = ctx.options; const secondaryStorage = options.secondaryStorage; const sessionExpiration = options.session?.expiresIn || 60 * 60 * 24 * 7; // 7 days - const { createWithHooks, updateWithHooks, updateManyWithHooks } = - getWithHooks(adapter, ctx); + const { + createWithHooks, + updateWithHooks, + updateManyWithHooks, + deleteWithHooks, + deleteManyWithHooks, + } = getWithHooks(adapter, ctx); async function refreshUserSessions(user: User) { if (!secondaryStorage) return; @@ -222,21 +227,23 @@ export const createInternalAdapter = ( } return total; }, - deleteUser: async (userId: string) => { + deleteUser: async (userId: string, context?: GenericEndpointContext) => { if (secondaryStorage) { await secondaryStorage.delete(`active-sessions-${userId}`); } if (!secondaryStorage || options.session?.storeSessionInDatabase) { - await (await getCurrentAdapter(adapter)).deleteMany({ - model: "session", - where: [ + await deleteManyWithHooks( + [ { field: "userId", value: userId, }, ], - }); + "session", + undefined, + context, + ); } await (await getCurrentAdapter(adapter)).deleteMany({ @@ -248,15 +255,17 @@ export const createInternalAdapter = ( }, ], }); - await (await getCurrentAdapter(adapter)).delete({ - model: "user", - where: [ + await deleteWithHooks( + [ { field: "id", value: userId, }, ], - }); + "user", + undefined, + context, + ); }, createSession: async ( userId: string, @@ -589,7 +598,10 @@ export const createInternalAdapter = ( ], }); }, - deleteSessions: async (userIdOrSessionTokens: string | string[]) => { + deleteSessions: async ( + userIdOrSessionTokens: string | string[], + context?: GenericEndpointContext, + ) => { if (secondaryStorage) { if (typeof userIdOrSessionTokens === "string") { const activeSession = await secondaryStorage.get( @@ -618,16 +630,18 @@ export const createInternalAdapter = ( return; } } - await (await getCurrentAdapter(adapter)).deleteMany({ - model: "session", - where: [ + await deleteManyWithHooks( + [ { field: Array.isArray(userIdOrSessionTokens) ? "token" : "userId", value: userIdOrSessionTokens, operator: Array.isArray(userIdOrSessionTokens) ? "in" : undefined, }, ], - }); + "session", + undefined, + context, + ); }, findOAuthUser: async ( email: string, @@ -966,7 +980,10 @@ export const createInternalAdapter = ( const lastVerification = verification[0]; return lastVerification as Verification | null; }, - deleteVerificationValue: async (id: string) => { + deleteVerificationValue: async ( + id: string, + context?: GenericEndpointContext, + ) => { await (await getCurrentAdapter(adapter)).delete({ model: "verification", where: [ @@ -977,7 +994,10 @@ export const createInternalAdapter = ( ], }); }, - deleteVerificationByIdentifier: async (identifier: string) => { + deleteVerificationByIdentifier: async ( + identifier: string, + context?: GenericEndpointContext, + ) => { await (await getCurrentAdapter(adapter)).delete({ model: "verification", where: [ diff --git a/packages/better-auth/src/db/with-hooks.ts b/packages/better-auth/src/db/with-hooks.ts index 4e0fdd759a..dcf219d088 100644 --- a/packages/better-auth/src/db/with-hooks.ts +++ b/packages/better-auth/src/db/with-hooks.ts @@ -160,9 +160,126 @@ export function getWithHooks( return updated; } + + async function deleteWithHooks>( + where: Where[], + model: BaseModels, + customDeleteFn?: { + fn: (where: Where[]) => void | Promise; + executeMainFn?: boolean; + }, + context?: GenericEndpointContext, + ) { + let entityToDelete: T | null = null; + + try { + const entities = await (await getCurrentAdapter(adapter)).findMany({ + model, + where, + limit: 1, + }); + entityToDelete = entities[0] || null; + } catch (error) { + // If we can't find the entity, we'll still proceed with deletion + } + + if (entityToDelete) { + for (const hook of hooks || []) { + const toRun = hook[model]?.delete?.before; + if (toRun) { + const result = await toRun(entityToDelete as any, context); + if (result === false) { + return null; + } + } + } + } + + const customDeleted = customDeleteFn + ? await customDeleteFn.fn(where) + : null; + + const deleted = + !customDeleteFn || customDeleteFn.executeMainFn + ? await (await getCurrentAdapter(adapter)).delete({ + model, + where, + }) + : customDeleted; + + if (entityToDelete) { + for (const hook of hooks || []) { + const toRun = hook[model]?.delete?.after; + if (toRun) { + await toRun(entityToDelete as any, context); + } + } + } + + return deleted; + } + + async function deleteManyWithHooks>( + where: Where[], + model: BaseModels, + customDeleteFn?: { + fn: (where: Where[]) => void | Promise; + executeMainFn?: boolean; + }, + context?: GenericEndpointContext, + ) { + let entitiesToDelete: T[] = []; + + try { + entitiesToDelete = await (await getCurrentAdapter(adapter)).findMany({ + model, + where, + }); + } catch (error) { + // If we can't find the entities, we'll still proceed with deletion + } + + for (const entity of entitiesToDelete) { + for (const hook of hooks || []) { + const toRun = hook[model]?.delete?.before; + if (toRun) { + const result = await toRun(entity as any, context); + if (result === false) { + return null; + } + } + } + } + + const customDeleted = customDeleteFn + ? await customDeleteFn.fn(where) + : null; + + const deleted = + !customDeleteFn || customDeleteFn.executeMainFn + ? await (await getCurrentAdapter(adapter)).deleteMany({ + model, + where, + }) + : customDeleted; + + for (const entity of entitiesToDelete) { + for (const hook of hooks || []) { + const toRun = hook[model]?.delete?.after; + if (toRun) { + await toRun(entity as any, context); + } + } + } + + return deleted; + } + return { createWithHooks, updateWithHooks, updateManyWithHooks, + deleteWithHooks, + deleteManyWithHooks, }; } diff --git a/packages/better-auth/src/types/options.ts b/packages/better-auth/src/types/options.ts index f87c3de871..0fe4e14b5c 100644 --- a/packages/better-auth/src/types/options.ts +++ b/packages/better-auth/src/types/options.ts @@ -860,6 +860,23 @@ export type BetterAuthOptions = { context?: GenericEndpointContext, ) => Promise; }; + delete?: { + /** + * Hook that is called before a user is deleted. + * if the hook returns false, the user will not be deleted. + */ + before?: ( + user: User & Record, + context?: GenericEndpointContext, + ) => Promise; + /** + * Hook that is called after a user is deleted. + */ + after?: ( + user: User & Record, + context?: GenericEndpointContext, + ) => Promise; + }; }; /** * Session Hook @@ -916,6 +933,23 @@ export type BetterAuthOptions = { context?: GenericEndpointContext, ) => Promise; }; + delete?: { + /** + * Hook that is called before a session is deleted. + * if the hook returns false, the session will not be deleted. + */ + before?: ( + session: Session & Record, + context?: GenericEndpointContext, + ) => Promise; + /** + * Hook that is called after a session is deleted. + */ + after?: ( + session: Session & Record, + context?: GenericEndpointContext, + ) => Promise; + }; }; /** * Account Hook @@ -972,6 +1006,23 @@ export type BetterAuthOptions = { context?: GenericEndpointContext, ) => Promise; }; + delete?: { + /** + * Hook that is called before an account is deleted. + * if the hook returns false, the account will not be deleted. + */ + before?: ( + account: Account & Record, + context?: GenericEndpointContext, + ) => Promise; + /** + * Hook that is called after an account is deleted. + */ + after?: ( + account: Account & Record, + context?: GenericEndpointContext, + ) => Promise; + }; }; /** * Verification Hook @@ -1025,6 +1076,23 @@ export type BetterAuthOptions = { context?: GenericEndpointContext, ) => Promise; }; + delete?: { + /** + * Hook that is called before a verification is deleted. + * if the hook returns false, the verification will not be deleted. + */ + before?: ( + verification: Verification & Record, + context?: GenericEndpointContext, + ) => Promise; + /** + * Hook that is called after a verification is deleted. + */ + after?: ( + verification: Verification & Record, + context?: GenericEndpointContext, + ) => Promise; + }; }; }; /**