diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts index a7e1389920..b087a6b538 100644 --- a/packages/backend-core/src/users/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -14,13 +14,14 @@ import { } from "../db" import { BulkDocsResponse, + ContextUser, + SearchQuery, + SearchQueryOperators, SearchUsersRequest, User, - ContextUser, } from "@budibase/types" -import { getGlobalDB } from "../context" import * as context from "../context" -import { user as userCache } from "../cache" +import { getGlobalDB } from "../context" type GetOpts = { cleanup?: boolean } @@ -39,6 +40,31 @@ function removeUserPassword(users: User | User[]) { return users } +export const isSupportedUserSearch = (query: SearchQuery) => { + const allowed = [ + { op: SearchQueryOperators.STRING, key: "email" }, + { op: SearchQueryOperators.EQUAL, key: "_id" }, + ] + for (let [key, operation] of Object.entries(query)) { + if (typeof operation !== "object") { + return false + } + const fields = Object.keys(operation || {}) + // this filter doesn't contain options - ignore + if (fields.length === 0) { + continue + } + const allowedOperation = allowed.find( + allow => + allow.op === key && fields.length === 1 && fields[0] === allow.key + ) + if (!allowedOperation) { + return false + } + } + return true +} + export const bulkGetGlobalUsersById = async ( userIds: string[], opts?: GetOpts @@ -211,8 +237,8 @@ export const searchGlobalUsersByEmail = async ( const PAGE_LIMIT = 8 export const paginatedUsers = async ({ - page, - email, + bookmark, + query, appId, }: SearchUsersRequest = {}) => { const db = getGlobalDB() @@ -222,18 +248,20 @@ export const paginatedUsers = async ({ limit: PAGE_LIMIT + 1, } // add a startkey if the page was specified (anchor) - if (page) { - opts.startkey = page + if (bookmark) { + opts.startkey = bookmark } // property specifies what to use for the page/anchor let userList: User[], property = "_id", getKey - if (appId) { + if (query?.equal?._id) { + userList = [await getById(query.equal._id)] + } else if (appId) { userList = await searchGlobalUsersByApp(appId, opts) getKey = (doc: any) => getGlobalUserByAppPage(appId, doc) - } else if (email) { - userList = await searchGlobalUsersByEmail(email, opts) + } else if (query?.string?.email) { + userList = await searchGlobalUsersByEmail(query?.string?.email, opts) property = "email" } else { // no search, query allDocs diff --git a/packages/builder/src/pages/builder/portal/account/auditLogs/index.svelte b/packages/builder/src/pages/builder/portal/account/auditLogs/index.svelte index a6dba59196..a5b0b19c9b 100644 --- a/packages/builder/src/pages/builder/portal/account/auditLogs/index.svelte +++ b/packages/builder/src/pages/builder/portal/account/auditLogs/index.svelte @@ -123,7 +123,10 @@ prevUserSearch = search try { userPageInfo.loading() - await users.search({ userPage, email: search }) + await users.search({ + bookmark: userPage, + query: { string: { email: search } }, + }) userPageInfo.fetched($users.hasNextPage, $users.nextPage) } catch (error) { notifications.error("Error getting user list") diff --git a/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte b/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte index da4b12f7f9..cc524f1acf 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte @@ -31,7 +31,10 @@ prevSearch = search try { pageInfo.loading() - await users.search({ page, email: search }) + await users.search({ + bookmark: page, + query: { string: { email: search } }, + }) pageInfo.fetched($users.hasNextPage, $users.nextPage) } catch (error) { notifications.error("Error getting user list") diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index 52faf46615..544a1a8434 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -105,19 +105,25 @@ } } - $: fetchRows(searchTerm, primaryDisplay) + $: fetchRows(searchTerm, primaryDisplay, defaultValue) - const fetchRows = (searchTerm, primaryDisplay) => { + const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => { const allRowsFetched = $fetch.loaded && !Object.keys($fetch.query?.string || {}).length && !$fetch.hasNextPage - // Don't request until we have the primary display - if (!allRowsFetched && primaryDisplay) { - fetch.update({ - query: { string: { [primaryDisplay]: searchTerm } }, + // Don't request until we have the primary display or default value has been fetched + if (allRowsFetched || !primaryDisplay) { + return + } + if (defaultVal && !optionsObj[defaultVal]) { + await fetch.update({ + query: { equal: { _id: defaultVal } }, }) } + await fetch.update({ + query: { string: { [primaryDisplay]: searchTerm } }, + }) } const flatten = values => { diff --git a/packages/frontend-core/src/api/user.js b/packages/frontend-core/src/api/user.js index 6c616d7baf..95c2167721 100644 --- a/packages/frontend-core/src/api/user.js +++ b/packages/frontend-core/src/api/user.js @@ -10,24 +10,28 @@ export const buildUserEndpoints = API => ({ /** * Gets a list of users in the current tenant. - * @param {string} page The page to retrieve - * @param {string} search The starts with string to search username/email by. + * @param {string} bookmark The page to retrieve + * @param {object} query search filters for lookup by user (all operators not supported). * @param {string} appId Facilitate app/role based user searching - * @param {boolean} paginated Allow the disabling of pagination + * @param {boolean} paginate Allow the disabling of pagination + * @param {number} limit How many users to retrieve in a single search */ - searchUsers: async ({ paginated, page, email, appId } = {}) => { + searchUsers: async ({ paginate, bookmark, query, appId, limit } = {}) => { const opts = {} - if (page) { - opts.page = page + if (bookmark) { + opts.bookmark = bookmark } - if (email) { - opts.email = email + if (query) { + opts.query = query } if (appId) { opts.appId = appId } - if (typeof paginated === "boolean") { - opts.paginated = paginated + if (typeof paginate === "boolean") { + opts.paginate = paginate + } + if (limit) { + opts.limit = limit } return await API.post({ url: `/api/global/users/search`, diff --git a/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte b/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte index 4e76c264a1..48b1279346 100644 --- a/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte @@ -27,7 +27,7 @@ const email = Object.values(searchParams.query.string)[0] const results = await API.searchUsers({ - email, + query: { string: { email } }, }) // Mapping to the expected data within RelationshipCell diff --git a/packages/frontend-core/src/fetch/UserFetch.js b/packages/frontend-core/src/fetch/UserFetch.js index 5372d0ec33..b1478c3a6d 100644 --- a/packages/frontend-core/src/fetch/UserFetch.js +++ b/packages/frontend-core/src/fetch/UserFetch.js @@ -1,6 +1,7 @@ import { get } from "svelte/store" import DataFetch from "./DataFetch.js" import { TableNames } from "../constants" +import { LuceneUtils } from "../utils" export default class UserFetch extends DataFetch { constructor(opts) { @@ -27,16 +28,25 @@ export default class UserFetch extends DataFetch { } async getData() { + const { limit, paginate } = this.options const { cursor, query } = get(this.store) + let finalQuery + // convert old format to new one - we now allow use of the lucene format + const { appId, paginated, ...rest } = query + if (!LuceneUtils.hasFilters(query) && rest.email) { + finalQuery = { string: { email: rest.email } } + } else { + finalQuery = rest + } try { - // "query" normally contains a lucene query, but users uses a non-standard - // search endpoint so we use query uniquely here - const res = await this.API.searchUsers({ - page: cursor, - email: query.email, - appId: query.appId, - paginated: query.paginated, - }) + const opts = { + bookmark: cursor, + query: finalQuery, + appId: appId, + paginate: paginated || paginate, + limit, + } + const res = await this.API.searchUsers(opts) return { rows: res?.data || [], hasNextPage: res?.hasNextPage || false, diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 0a80253210..4c2e7a7494 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -16,6 +16,7 @@ import { RelationshipType, Row, SaveTableRequest, + SearchQueryOperators, SortOrder, SortType, StaticQuotaName, @@ -1141,7 +1142,9 @@ describe.each([ ) const createViewResponse = await config.createView({ - query: [{ operator: "equal", field: "age", value: 40 }], + query: [ + { operator: SearchQueryOperators.EQUAL, field: "age", value: 40 }, + ], schema: viewSchema, }) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 6d893c1c7f..40060aef48 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -3,6 +3,7 @@ import { CreateViewRequest, FieldSchema, FieldType, + SearchQueryOperators, SortOrder, SortType, Table, @@ -89,7 +90,13 @@ describe.each([ name: generator.name(), tableId: table._id!, primaryDisplay: generator.word(), - query: [{ operator: "equal", field: "field", value: "value" }], + query: [ + { + operator: SearchQueryOperators.EQUAL, + field: "field", + value: "value", + }, + ], sort: { field: "fieldToSort", order: SortOrder.DESCENDING, @@ -184,7 +191,13 @@ describe.each([ const tableId = table._id! await config.api.viewV2.update({ ...view, - query: [{ operator: "equal", field: "newField", value: "thatValue" }], + query: [ + { + operator: SearchQueryOperators.EQUAL, + field: "newField", + value: "thatValue", + }, + ], }) expect((await config.api.table.get(tableId)).views).toEqual({ @@ -207,7 +220,7 @@ describe.each([ primaryDisplay: generator.word(), query: [ { - operator: "equal", + operator: SearchQueryOperators.EQUAL, field: generator.word(), value: generator.word(), }, @@ -279,7 +292,13 @@ describe.each([ { ...view, tableId: generator.guid(), - query: [{ operator: "equal", field: "newField", value: "thatValue" }], + query: [ + { + operator: SearchQueryOperators.EQUAL, + field: "newField", + value: "thatValue", + }, + ], }, { expectStatus: 404 } ) diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index ab43e86279..1839a53525 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -1,12 +1,13 @@ import { Datasource, + FieldSubtype, FieldType, - SortDirection, - SortType, SearchFilter, SearchQuery, SearchQueryFields, - FieldSubtype, + SearchQueryOperators, + SortDirection, + SortType, } from "@budibase/types" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" import { deepGet } from "./helpers" @@ -273,22 +274,30 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => { } // Process a string match (fails if the value does not start with the string) - const stringMatch = match("string", (docValue: string, testValue: string) => { - return ( - !docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) - ) - }) + const stringMatch = match( + SearchQueryOperators.STRING, + (docValue: string, testValue: string) => { + return ( + !docValue || + !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) + ) + } + ) // Process a fuzzy match (treat the same as starts with when running locally) - const fuzzyMatch = match("fuzzy", (docValue: string, testValue: string) => { - return ( - !docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) - ) - }) + const fuzzyMatch = match( + SearchQueryOperators.FUZZY, + (docValue: string, testValue: string) => { + return ( + !docValue || + !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) + ) + } + ) // Process a range match const rangeMatch = match( - "range", + SearchQueryOperators.RANGE, ( docValue: string | number | null, testValue: { low: number; high: number } @@ -304,7 +313,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => { // Process an equal match (fails if the value is different) const equalMatch = match( - "equal", + SearchQueryOperators.EQUAL, (docValue: any, testValue: string | null) => { return testValue != null && testValue !== "" && docValue !== testValue } @@ -312,46 +321,58 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => { // Process a not-equal match (fails if the value is the same) const notEqualMatch = match( - "notEqual", + SearchQueryOperators.NOT_EQUAL, (docValue: any, testValue: string | null) => { return testValue != null && testValue !== "" && docValue === testValue } ) // Process an empty match (fails if the value is not empty) - const emptyMatch = match("empty", (docValue: string | null) => { - return docValue != null && docValue !== "" - }) + const emptyMatch = match( + SearchQueryOperators.EMPTY, + (docValue: string | null) => { + return docValue != null && docValue !== "" + } + ) // Process a not-empty match (fails is the value is empty) - const notEmptyMatch = match("notEmpty", (docValue: string | null) => { - return docValue == null || docValue === "" - }) + const notEmptyMatch = match( + SearchQueryOperators.NOT_EMPTY, + (docValue: string | null) => { + return docValue == null || docValue === "" + } + ) // Process an includes match (fails if the value is not included) - const oneOf = match("oneOf", (docValue: any, testValue: any) => { - if (typeof testValue === "string") { - testValue = testValue.split(",") - if (typeof docValue === "number") { - testValue = testValue.map((item: string) => parseFloat(item)) + const oneOf = match( + SearchQueryOperators.ONE_OF, + (docValue: any, testValue: any) => { + if (typeof testValue === "string") { + testValue = testValue.split(",") + if (typeof docValue === "number") { + testValue = testValue.map((item: string) => parseFloat(item)) + } } + return !testValue?.includes(docValue) } - return !testValue?.includes(docValue) - }) + ) - const containsAny = match("containsAny", (docValue: any, testValue: any) => { - return !docValue?.includes(...testValue) - }) + const containsAny = match( + SearchQueryOperators.CONTAINS_ANY, + (docValue: any, testValue: any) => { + return !docValue?.includes(...testValue) + } + ) const contains = match( - "contains", + SearchQueryOperators.CONTAINS, (docValue: string | any[], testValue: any[]) => { return !testValue?.every((item: any) => docValue?.includes(item)) } ) const notContains = match( - "notContains", + SearchQueryOperators.NOT_CONTAINS, (docValue: string | any[], testValue: any[]) => { return testValue?.every((item: any) => docValue?.includes(item)) } @@ -433,7 +454,7 @@ export const hasFilters = (query?: SearchQuery) => { if (skipped.includes(key) || typeof value !== "object") { continue } - if (Object.keys(value).length !== 0) { + if (Object.keys(value || {}).length !== 0) { return true } } diff --git a/packages/types/src/api/web/searchFilter.ts b/packages/types/src/api/web/searchFilter.ts index 6980bc117b..ac3c446e36 100644 --- a/packages/types/src/api/web/searchFilter.ts +++ b/packages/types/src/api/web/searchFilter.ts @@ -10,43 +10,57 @@ export type SearchFilter = { externalType?: string } +export enum SearchQueryOperators { + STRING = "string", + FUZZY = "fuzzy", + RANGE = "range", + EQUAL = "equal", + NOT_EQUAL = "notEqual", + EMPTY = "empty", + NOT_EMPTY = "notEmpty", + ONE_OF = "oneOf", + CONTAINS = "contains", + NOT_CONTAINS = "notContains", + CONTAINS_ANY = "containsAny", +} + export type SearchQuery = { allOr?: boolean onEmptyFilter?: EmptyFilterOption - string?: { + [SearchQueryOperators.STRING]?: { [key: string]: string } - fuzzy?: { + [SearchQueryOperators.FUZZY]?: { [key: string]: string } - range?: { + [SearchQueryOperators.RANGE]?: { [key: string]: { high: number | string low: number | string } } - equal?: { + [SearchQueryOperators.EQUAL]?: { [key: string]: any } - notEqual?: { + [SearchQueryOperators.NOT_EQUAL]?: { [key: string]: any } - empty?: { + [SearchQueryOperators.EMPTY]?: { [key: string]: any } - notEmpty?: { + [SearchQueryOperators.NOT_EMPTY]?: { [key: string]: any } - oneOf?: { + [SearchQueryOperators.ONE_OF]?: { [key: string]: any[] } - contains?: { + [SearchQueryOperators.CONTAINS]?: { [key: string]: any[] } - notContains?: { + [SearchQueryOperators.NOT_CONTAINS]?: { [key: string]: any[] } - containsAny?: { + [SearchQueryOperators.CONTAINS_ANY]?: { [key: string]: any[] } } diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 85e2d89ad1..a1e039cfd7 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -1,4 +1,5 @@ import { User } from "../../documents" +import { SearchQuery } from "./searchFilter" export interface SaveUserResponse { _id: string @@ -51,10 +52,10 @@ export interface InviteUsersResponse { } export interface SearchUsersRequest { - page?: string - email?: string + bookmark?: string + query?: SearchQuery appId?: string - paginated?: boolean + paginate?: boolean } export interface CreateAdminUserRequest { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 822a16d33e..8de3a1444e 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -197,7 +197,12 @@ export const getAppUsers = async (ctx: Ctx) => { export const search = async (ctx: Ctx) => { const body = ctx.request.body - if (body.paginated === false) { + // TODO: for now only one supported search key, string.email + if (body?.query && !userSdk.core.isSupportedUserSearch(body.query)) { + ctx.throw(501, "Can only search by string.email or equal._id") + } + + if (body.paginate === false) { await getAppUsers(ctx) } else { const paginated = await userSdk.core.paginatedUsers(body) diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts index e4504eccfe..a446d10ed0 100644 --- a/packages/worker/src/api/routes/global/tests/users.spec.ts +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -544,6 +544,36 @@ describe("/api/global/users", () => { }) }) + describe("POST /api/global/users/search", () => { + it("should be able to search by email", async () => { + const user = await config.createUser() + const response = await config.api.users.searchUsers({ + query: { string: { email: user.email } }, + }) + expect(response.body.data.length).toBe(1) + expect(response.body.data[0].email).toBe(user.email) + }) + + it("should be able to search by _id", async () => { + const user = await config.createUser() + const response = await config.api.users.searchUsers({ + query: { equal: { _id: user._id } }, + }) + expect(response.body.data.length).toBe(1) + expect(response.body.data[0]._id).toBe(user._id) + }) + + it("should throw an error when unimplemented options used", async () => { + const user = await config.createUser() + await config.api.users.searchUsers( + { + query: { equal: { firstName: user.firstName } }, + }, + 501 + ) + }) + }) + describe("DELETE /api/global/users/:userId", () => { it("should be able to destroy a basic user", async () => { const user = await config.createUser() diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts index e96209eca6..b2a19bcb28 100644 --- a/packages/worker/src/tests/api/users.ts +++ b/packages/worker/src/tests/api/users.ts @@ -4,6 +4,7 @@ import { InviteUsersRequest, User, CreateAdminUserRequest, + SearchQuery, } from "@budibase/types" import structures from "../structures" import { generator } from "@budibase/backend-core/tests" @@ -133,6 +134,15 @@ export class UserAPI extends TestAPI { .expect(status ? status : 200) } + searchUsers = ({ query }: { query?: SearchQuery }, status = 200) => { + return this.request + .post("/api/global/users/search") + .set(this.config.defaultHeaders()) + .send({ query }) + .expect("Content-Type", /json/) + .expect(status ? status : 200) + } + getUser = (userId: string, opts?: TestAPIOpts) => { return this.request .get(`/api/global/users/${userId}`) diff --git a/yarn.lock b/yarn.lock index 81c2815663..11d296b296 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21750,7 +21750,7 @@ vlq@^0.2.2: resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== -vm2@^3.9.19: +vm2@^3.9.19, vm2@^3.9.8: version "3.9.19" resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.19.tgz#be1e1d7a106122c6c492b4d51c2e8b93d3ed6a4a" integrity sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==