From 07ad83fa1f4510f920f515c237ce78a68ec6beec Mon Sep 17 00:00:00 2001 From: Yasser5711 Date: Fri, 14 Nov 2025 17:29:43 +0100 Subject: [PATCH] feat(admin): implement multi-field search with customizable search mode --- docs/content/docs/plugins/admin.mdx | 11 ++ .../src/plugins/admin/admin.test.ts | 102 ++++++++++++++++++ .../better-auth/src/plugins/admin/admin.ts | 47 +++++++- 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/content/docs/plugins/admin.mdx b/docs/content/docs/plugins/admin.mdx index 2545bacf37..f3387d4273 100644 --- a/docs/content/docs/plugins/admin.mdx +++ b/docs/content/docs/plugins/admin.mdx @@ -130,6 +130,17 @@ type listUsers = { * The operator to use for the search. Can be `contains`, `starts_with` or `ends_with`. */ searchOperator?: "contains" | "starts_with" | "ends_with" = "contains" + /** + * The fields to search in when `searchField` is not provided. + * Example: ["email", "name"] + */ + searchInFields?: string[] = ["email"] + /** + * How to combine multiple fields from `searchInFields`. + * - OR: match any field (default) + * - AND: match all fields + */ + searchMode?: "OR" | "AND" = "OR" /** * The number of users to return. Defaults to 100. */ diff --git a/packages/better-auth/src/plugins/admin/admin.test.ts b/packages/better-auth/src/plugins/admin/admin.test.ts index a5cdee4aea..e3ebfba718 100644 --- a/packages/better-auth/src/plugins/admin/admin.test.ts +++ b/packages/better-auth/src/plugins/admin/admin.test.ts @@ -323,7 +323,109 @@ describe("Admin plugin", async () => { }); expect(res.data?.users.length).toBe(1); }); + it("should allow multi-field search with OR (default) using searchInFields", async () => { + const user1 = await client.admin.createUser( + { + name: "user1", + email: "user2@user4.com", + password: "password", + role: "user", + }, + { + headers: adminHeaders, + }, + ); + const user2 = await client.admin.createUser( + { + name: "user5", + email: "user6@user1.com", + password: "password", + role: "user", + }, + { + headers: adminHeaders, + }, + ); + expect(user1.data?.user.id).toBeDefined(); + expect(user2.data?.user.id).toBeDefined(); + const users = [user1.data?.user, user2.data?.user]; + const res = await client.admin.listUsers({ + query: { + searchValue: "user1", + searchInFields: ["email", "name"], + searchOperator: "contains", + }, + fetchOptions: { + headers: adminHeaders, + }, + }); + expect(res.error).toBeNull(); + expect(res.data?.users).toEqual(users); + for (const user of res.data?.users ?? []) { + await client.admin.removeUser( + { + userId: user.id, + }, + { + headers: adminHeaders, + }, + ); + } + }); + + it("should support searchMode AND when using searchInFields", async () => { + const user1 = await client.admin.createUser( + { + name: "user1", + email: "user2@user4.com", + password: "password", + role: "user", + }, + { + headers: adminHeaders, + }, + ); + const user2 = await client.admin.createUser( + { + name: "user5", + email: "user6@user1.com", + password: "password", + role: "user", + }, + { + headers: adminHeaders, + }, + ); + expect(user1.data?.user.id).toBeDefined(); + expect(user2.data?.user.id).toBeDefined(); + const users = [user1.data?.user, user2.data?.user]; + + const res = await client.admin.listUsers({ + query: { + searchValue: "user1", + searchInFields: ["email", "name"], + searchOperator: "contains", + searchMode: "AND", + }, + fetchOptions: { + headers: adminHeaders, + }, + }); + expect(res.error).toBeNull(); + expect(res.data?.users).toEqual([]); + + for (const user of res.data?.users ?? []) { + await client.admin.removeUser( + { + userId: user.id, + }, + { + headers: adminHeaders, + }, + ); + } + }); it("should allow to filter users by role", async () => { const res = await client.admin.listUsers({ query: { diff --git a/packages/better-auth/src/plugins/admin/admin.ts b/packages/better-auth/src/plugins/admin/admin.ts index 1ce66af632..c8bc9d12e5 100644 --- a/packages/better-auth/src/plugins/admin/admin.ts +++ b/packages/better-auth/src/plugins/admin/admin.ts @@ -618,6 +618,24 @@ export const admin = (options?: O | undefined) => { 'The operator to use for the search. Can be `contains`, `starts_with` or `ends_with`. Eg: "contains"', }) .optional(), + searchInFields: z + .union([z.array(z.string()), z.string()]) + .optional() + .transform((value) => { + if (!value) return undefined; + if (Array.isArray(value)) return value; + return value.split(",").map((v) => v.trim()); + }) + + .meta({ + description: + "Fields to search across if searchField is not provided", + }), + + searchMode: z.enum(["OR", "AND"]).optional().default("OR").meta({ + description: + "Combine multi-field search with OR (default) or AND", + }), limit: z .string() .meta({ @@ -722,11 +740,30 @@ export const admin = (options?: O | undefined) => { const where: Where[] = []; if (ctx.query?.searchValue) { - where.push({ - field: ctx.query.searchField || "email", - operator: ctx.query.searchOperator || "contains", - value: ctx.query.searchValue, - }); + const operator = ctx.query.searchOperator || "contains"; + const searchValue = ctx.query.searchValue; + if (ctx.query.searchField) { + where.push({ + field: ctx.query.searchField, + operator, + value: searchValue, + }); + } else { + const fields = ctx.query.searchInFields?.length + ? ctx.query.searchInFields + : ["email"]; // default + + const connector = ctx.query.searchMode || "OR"; + + fields.forEach((field) => { + where.push({ + field, + operator, + value: searchValue, + connector, + }); + }); + } } if (ctx.query?.filterValue) {