mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-25 16:36:34 -05:00
feat(db): delete hooks (#4792)
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
committed by
GitHub
parent
7eefc93c59
commit
eb76a7fc68
@@ -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`);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Verification>({
|
||||
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<Verification>({
|
||||
model: "verification",
|
||||
where: [
|
||||
|
||||
@@ -160,9 +160,126 @@ export function getWithHooks(
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function deleteWithHooks<T extends Record<string, any>>(
|
||||
where: Where[],
|
||||
model: BaseModels,
|
||||
customDeleteFn?: {
|
||||
fn: (where: Where[]) => void | Promise<any>;
|
||||
executeMainFn?: boolean;
|
||||
},
|
||||
context?: GenericEndpointContext,
|
||||
) {
|
||||
let entityToDelete: T | null = null;
|
||||
|
||||
try {
|
||||
const entities = await (await getCurrentAdapter(adapter)).findMany<T>({
|
||||
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<T extends Record<string, any>>(
|
||||
where: Where[],
|
||||
model: BaseModels,
|
||||
customDeleteFn?: {
|
||||
fn: (where: Where[]) => void | Promise<any>;
|
||||
executeMainFn?: boolean;
|
||||
},
|
||||
context?: GenericEndpointContext,
|
||||
) {
|
||||
let entitiesToDelete: T[] = [];
|
||||
|
||||
try {
|
||||
entitiesToDelete = await (await getCurrentAdapter(adapter)).findMany<T>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -860,6 +860,23 @@ export type BetterAuthOptions = {
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<void>;
|
||||
};
|
||||
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<string, unknown>,
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<boolean | void>;
|
||||
/**
|
||||
* Hook that is called after a user is deleted.
|
||||
*/
|
||||
after?: (
|
||||
user: User & Record<string, unknown>,
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<void>;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Session Hook
|
||||
@@ -916,6 +933,23 @@ export type BetterAuthOptions = {
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<void>;
|
||||
};
|
||||
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<string, unknown>,
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<boolean | void>;
|
||||
/**
|
||||
* Hook that is called after a session is deleted.
|
||||
*/
|
||||
after?: (
|
||||
session: Session & Record<string, unknown>,
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<void>;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Account Hook
|
||||
@@ -972,6 +1006,23 @@ export type BetterAuthOptions = {
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<void>;
|
||||
};
|
||||
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<string, unknown>,
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<boolean | void>;
|
||||
/**
|
||||
* Hook that is called after an account is deleted.
|
||||
*/
|
||||
after?: (
|
||||
account: Account & Record<string, unknown>,
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<void>;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Verification Hook
|
||||
@@ -1025,6 +1076,23 @@ export type BetterAuthOptions = {
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<void>;
|
||||
};
|
||||
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<string, unknown>,
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<boolean | void>;
|
||||
/**
|
||||
* Hook that is called after a verification is deleted.
|
||||
*/
|
||||
after?: (
|
||||
verification: Verification & Record<string, unknown>,
|
||||
context?: GenericEndpointContext,
|
||||
) => Promise<void>;
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user