diff --git a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx index 1ab879906d4..900e42ee314 100644 --- a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx @@ -21,6 +21,7 @@ interface SortDropdownProps { showSortByEmail: boolean showSortByPhone: boolean setSortByValue: (value: string) => void + improvedSearchEnabled: boolean } export const SortDropdown = ({ @@ -31,8 +32,9 @@ export const SortDropdown = ({ showSortByEmail, showSortByPhone, setSortByValue, + improvedSearchEnabled = false, }: SortDropdownProps) => { - if (specificFilterColumn !== 'freeform') { + if (specificFilterColumn !== 'freeform' && !improvedSearchEnabled) { return ( { + switch (column) { + case 'id': + return 'Search by user ID' + case 'email': + return 'Search by email' + case 'name': + return 'Search by name' + case 'phone': + return 'Search by phone' + case 'freeform': + return 'Search by user ID, email, phone or name' + default: + return 'Search users...' + } +} + import { Button, cn, @@ -20,10 +39,11 @@ import { Input } from 'ui-patterns/DataInputs/Input' interface UsersSearchProps { search: string searchInvalid: boolean - specificFilterColumn: 'id' | 'email' | 'phone' | 'freeform' + specificFilterColumn: SpecificFilterColumn setSearch: (value: SetStateAction) => void setFilterKeywords: (value: string) => void - setSpecificFilterColumn: (value: 'id' | 'email' | 'phone' | 'freeform') => void + setSpecificFilterColumn: (value: SpecificFilterColumn) => void + improvedSearchEnabled?: boolean } export const UsersSearch = ({ @@ -33,6 +53,7 @@ export const UsersSearch = ({ setSearch, setFilterKeywords, setSpecificFilterColumn, + improvedSearchEnabled = false, }: UsersSearchProps) => { return (
@@ -61,21 +82,30 @@ export const UsersSearch = ({ Email address + {improvedSearchEnabled && ( + + Name + + )} Phone number - - - - - Unified search - - - - Search by all columns at once, including mid-string search. May impact database - performance if you have many users. - - + {!improvedSearchEnabled && ( + <> + + + + + Unified search + + + + Search by all columns at once, including mid-string search. May impact database + performance if you have many users. + + + + )} @@ -87,11 +117,7 @@ export const UsersSearch = ({ searchInvalid ? 'text-red-900 dark:border-red-900' : '', search.length > 1 && 'pr-6' )} - placeholder={ - specificFilterColumn === 'freeform' - ? 'Search by user ID, email, phone or name' - : `Search by ${specificFilterColumn === 'id' ? 'User ID' : specificFilterColumn === 'email' ? 'Email' : 'Phone'}` - } + placeholder={getSearchPlaceholder(specificFilterColumn)} value={search} onChange={(e) => { const value = e.target.value.replace(/\s+/g, '').toLowerCase() @@ -99,13 +125,6 @@ export const UsersSearch = ({ }} onKeyDown={(e) => { if (e.code === 'Enter' || e.code === 'NumpadEnter') { - setSearch((s) => { - if (s && specificFilterColumn === 'phone' && !s.startsWith('+')) { - return `+${s}` - } else { - return s - } - }) if (!searchInvalid) setFilterKeywords(search.trim().toLocaleLowerCase()) } }} diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 277fc6c35a8..4ff3b6671d1 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -122,16 +122,20 @@ export const UsersV2 = () => { const [specificFilterColumn, setSpecificFilterColumn] = useQueryState( 'filter', - parseAsStringEnum(['id', 'email', 'phone', 'freeform']).withDefault( - 'email' - ) + parseAsStringEnum([ + 'id', + 'email', + 'phone', + 'name', + 'freeform', + ]).withDefault('email') ) const [filterUserType, setFilterUserType] = useQueryState( 'userType', parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all') ) const [filterKeywords, setFilterKeywords] = useQueryState('keywords', { defaultValue: '' }) - const [sortByValue, setSortByValue] = useQueryState('sortBy', { defaultValue: 'id:asc' }) + const [sortByValue, setSortByValue] = useQueryState('sortBy', { defaultValue: 'created_at:desc' }) const [sortColumn, sortOrder] = sortByValue.split(':') const [selectedColumns, setSelectedColumns] = useQueryState( 'columns', @@ -149,7 +153,7 @@ export const UsersV2 = () => { // [Joshen] Opting to store filter column, into local storage for now, which will initialize // the page when landing on auth users page only if no query params for filter column provided const [localStorageFilter, setLocalStorageFilter, { isSuccess: isLocalStorageFilterLoaded }] = - useLocalStorageQuery<'id' | 'email' | 'phone' | 'freeform'>( + useLocalStorageQuery( LOCAL_STORAGE_KEYS.AUTH_USERS_FILTER(projectRef ?? ''), 'email' ) @@ -198,6 +202,76 @@ export const UsersV2 = () => { const totalUsers = totalUsersCountData?.count ?? 0 const isCountWithinThresholdForSortBy = totalUsers <= SORT_BY_VALUE_COUNT_THRESHOLD + const isImprovedUserSearchFlagEnabled = useFlag('improvedUserSearch') + const { data: authConfig, isLoading: isAuthConfigLoading } = useAuthConfigQuery({ projectRef }) + const { + data: userSearchIndexes, + isError: isUserSearchIndexesError, + isLoading: isUserSearchIndexesLoading, + } = useUserIndexStatusesQuery({ projectRef, connectionString: project?.connectionString }) + const { data: indexWorkerStatus, isLoading: isIndexWorkerStatusLoading } = + useIndexWorkerStatusQuery({ + projectRef, + connectionString: project?.connectionString, + }) + const { mutate: updateAuthConfig, isPending: isUpdatingAuthConfig } = useAuthConfigUpdateMutation( + { + onSuccess: () => { + toast.success('Initiated creation of user search indexes') + }, + onError: (error) => { + toast.error(`Failed to initiate creation of user search indexes: ${error?.message}`) + }, + } + ) + + const handleEnableUserSearchIndexes = () => { + if (!projectRef) return console.error('Project ref is required') + updateAuthConfig({ + projectRef: projectRef, + config: { INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST: true }, + }) + } + + const userSearchIndexesAreValidAndReady = + !isUserSearchIndexesError && + !isUserSearchIndexesLoading && + userSearchIndexes?.length === pgMeta.USER_SEARCH_INDEXES.length && + userSearchIndexes?.every((index) => index.is_valid && index.is_ready) + + /** + * We want to show the improved search when: + * 1. The feature flag is enabled for them + * 2. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true) + * 3. The required indexes are valid and ready + */ + const improvedSearchEnabled = + isImprovedUserSearchFlagEnabled && + authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true && + userSearchIndexesAreValidAndReady + + /** + * We want to show users the improved search opt-in only if: + * 1. The feature flag is enabled for them + * 2. They have not opted in yet (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is false) + * 3. They have < threshold number of users + */ + const isCountWithinThresholdForOptIn = + isCountLoaded && totalUsers <= IMPROVED_SEARCH_COUNT_THRESHOLD + const showImprovedSearchOptIn = + isImprovedUserSearchFlagEnabled && + authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === false && + isCountWithinThresholdForOptIn + + /** + * We want to show an "in progress" state when: + * 1. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true) + * 2. The index worker is currently in progress + */ + const indexWorkerInProgress = + authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true && + indexWorkerStatus?.is_in_progress === true + const { data, error, @@ -215,21 +289,26 @@ export const UsersV2 = () => { connectionString: project?.connectionString, keywords: filterKeywords, filter: - specificFilterColumn !== 'freeform' || filterUserType === 'all' + (specificFilterColumn !== 'freeform' && !improvedSearchEnabled) || filterUserType === 'all' ? undefined : filterUserType, providers: selectedProviders, sort: sortColumn as 'id' | 'created_at' | 'email' | 'phone', order: sortOrder as 'asc' | 'desc', - ...(specificFilterColumn !== 'freeform' + // improved search will always have a column specified + ...(specificFilterColumn !== 'freeform' || improvedSearchEnabled ? { column: specificFilterColumn as OptimizedSearchColumns } : { column: undefined }), + + improvedSearchEnabled: improvedSearchEnabled, }, { placeholderData: Boolean(filterKeywords) ? keepPreviousData : undefined, // [Joshen] This is to prevent the dashboard from invalidating when refocusing as it may create // a barrage of requests to invalidate each page esp when the project has many many users. staleTime: Infinity, + // NOTE(iat): query the user data only after we know whether to show improved search or not + enabled: !isUserSearchIndexesLoading && !isAuthConfigLoading && !isIndexWorkerStatusLoading, } ) @@ -242,7 +321,10 @@ export const UsersV2 = () => { const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0]) const searchInvalid = - !search || specificFilterColumn === 'freeform' || specificFilterColumn === 'email' + !search || + specificFilterColumn === 'freeform' || + specificFilterColumn === 'email' || + specificFilterColumn === 'name' ? false : specificFilterColumn === 'id' ? !search.match(UUIDV4_LEFT_PREFIX_REGEX) @@ -261,10 +343,10 @@ export const UsersV2 = () => { organization: selectedOrg?.slug ?? 'Unknown', } - const updateStorageFilter = (value: 'id' | 'email' | 'phone' | 'freeform') => { + const updateStorageFilter = (value: SpecificFilterColumn) => { setLocalStorageFilter(value) setSpecificFilterColumn(value) - if (value !== 'freeform') { + if (value !== 'freeform' && !improvedSearchEnabled) { updateSortByValue('id:asc') } } @@ -350,74 +432,6 @@ export const UsersV2 = () => { } } - const isImprovedUserSearchEnabled = useFlag('improvedUserSearch') - const { data: authConfig } = useAuthConfigQuery({ projectRef }) - const { - data: userSearchIndexes, - isError: isUserSearchIndexesError, - isLoading: isUserSearchIndexesLoading, - } = useUserIndexStatusesQuery({ projectRef, connectionString: project?.connectionString }) - const { data: indexWorkerStatus } = useIndexWorkerStatusQuery({ - projectRef, - connectionString: project?.connectionString, - }) - const { mutate: updateAuthConfig, isPending: isUpdatingAuthConfig } = useAuthConfigUpdateMutation( - { - onSuccess: () => { - toast.success('Initiated creation of user search indexes') - }, - onError: (error) => { - toast.error(`Failed to initiate creation of user search indexes: ${error?.message}`) - }, - } - ) - - const handleEnableUserSearchIndexes = () => { - if (!projectRef) return console.error('Project ref is required') - updateAuthConfig({ - projectRef: projectRef, - config: { INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST: true }, - }) - } - - const userSearchIndexesAreValidAndReady = - !isUserSearchIndexesError && - !isUserSearchIndexesLoading && - userSearchIndexes?.length === pgMeta.USER_SEARCH_INDEXES.length && - userSearchIndexes?.every((index) => index.is_valid && index.is_ready) - - /** - * We want to show the improved search when: - * 1. The feature flag is enabled for them - * 2. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true) - * 3. The required indexes are valid and ready - */ - const _showImprovedSearch = - isImprovedUserSearchEnabled && - authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true && - userSearchIndexesAreValidAndReady - - /** - * We want to show users the improved search opt-in only if: - * 1. The feature flag is enabled for them - * 2. They have not opted in yet (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is false) - * 3. They have < threshold number of users - */ - const isCountWithinThresholdForOptIn = totalUsers <= IMPROVED_SEARCH_COUNT_THRESHOLD - const showImprovedSearchOptIn = - isImprovedUserSearchEnabled && - authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === false && - isCountWithinThresholdForOptIn - - /** - * We want to show an "in progress" state when: - * 1. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true) - * 2. The index worker is currently in progress - */ - const indexWorkerInProgress = - authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true && - indexWorkerStatus?.is_in_progress === true - useEffect(() => { if ( !isRefetching && @@ -562,77 +576,80 @@ export const UsersV2 = () => { updateStorageFilter(value) } }} + improvedSearchEnabled={improvedSearchEnabled} /> - {showUserTypeFilter && specificFilterColumn === 'freeform' && ( - { - setFilterUserType(val as Filter) - sendEvent({ - action: 'auth_users_search_submitted', - properties: { - trigger: 'user_type_filter', - ...telemetryProps, - user_type: val, - }, - groups: telemetryGroups, - }) - }} - > - { + setFilterUserType(val as Filter) + sendEvent({ + action: 'auth_users_search_submitted', + properties: { + trigger: 'user_type_filter', + ...telemetryProps, + user_type: val, + }, + groups: telemetryGroups, + }) + }} > - - - - - - All users - - - Verified users - - - Unverified users - - - Anonymous users - - - - - )} + + + + + + + All users + + + Verified users + + + Unverified users + + + Anonymous users + + + + + )} - {showProviderFilter && specificFilterColumn === 'freeform' && ( - { - setSelectedProviders(providers) - sendEvent({ - action: 'auth_users_search_submitted', - properties: { - trigger: 'provider_filter', - ...telemetryProps, - providers, - }, - groups: telemetryGroups, - }) - }} - /> - )} + {showProviderFilter && + (specificFilterColumn === 'freeform' || improvedSearchEnabled) && ( + { + setSelectedProviders(providers) + sendEvent({ + action: 'auth_users_search_submitted', + properties: { + trigger: 'provider_filter', + ...telemetryProps, + providers, + }, + groups: telemetryGroups, + }) + }} + /> + )}
@@ -704,6 +721,7 @@ export const UsersV2 = () => { }} showSortByEmail={showSortByEmail} showSortByPhone={showSortByPhone} + improvedSearchEnabled={improvedSearchEnabled} />
diff --git a/apps/studio/data/auth/users-infinite-query.ts b/apps/studio/data/auth/users-infinite-query.ts index c924b7d36d1..0db05a99c2e 100644 --- a/apps/studio/data/auth/users-infinite-query.ts +++ b/apps/studio/data/auth/users-infinite-query.ts @@ -1,4 +1,7 @@ -import { getPaginatedUsersSQL } from '@supabase/pg-meta/src/sql/studio/get-users-paginated' +import { + getPaginatedUsersSQL, + UsersCursor, +} from '@supabase/pg-meta/src/sql/studio/get-users-paginated' import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types' @@ -24,6 +27,8 @@ type UsersVariables = { /** If set, uses optimized prefix search for the specified column */ column?: OptimizedSearchColumns startAt?: string + + improvedSearchEnabled?: boolean } export type Filter = 'verified' | 'unverified' | 'anonymous' @@ -39,6 +44,8 @@ export const useUsersInfiniteQuery = ( sort, order, column, + + improvedSearchEnabled = false, }: UsersVariables, { enabled = true, @@ -48,7 +55,7 @@ export const useUsersInfiniteQuery = ( UsersError, InfiniteData, readonly unknown[], - string | number | undefined + string | number | UsersCursor | undefined > = {} ) => { const { data: project } = useSelectedProjectQuery() @@ -78,6 +85,8 @@ export const useUsersInfiniteQuery = ( limit: USERS_PAGE_LIMIT, column, startAt: column ? (pageParam as string) : undefined, + cursor: improvedSearchEnabled ? (pageParam as UsersCursor) : undefined, + improvedSearchEnabled, }), }, signal @@ -87,14 +96,22 @@ export const useUsersInfiniteQuery = ( initialPageParam: undefined, getNextPageParam(lastPage, pages) { const hasNextPage = lastPage.result.length >= USERS_PAGE_LIMIT + if (!hasNextPage) return undefined + + const lastItem = lastPage.result[lastPage.result.length - 1] + if (!lastItem) return undefined + + // for improved search, we always use cursor-based pagination where the ORDER BY + // clause is the specified sort column + the id column as a tie breaker + if (improvedSearchEnabled) { + const sortColumn = sort ?? 'created_at' + return { sort: lastItem[sortColumn], id: lastItem.id } as UsersCursor + } + if (column) { - const lastItem = lastPage.result[lastPage.result.length - 1] - if (hasNextPage && lastItem) return lastItem[column] - return undefined + return lastItem[column as Exclude] } else { - const page = pages.length - if (!hasNextPage) return undefined - return page + return pages.length } }, ...options, diff --git a/packages/pg-meta/src/sql/studio/get-index-statuses.ts b/packages/pg-meta/src/sql/studio/get-index-statuses.ts index cd50dc0fd90..8b097172705 100644 --- a/packages/pg-meta/src/sql/studio/get-index-statuses.ts +++ b/packages/pg-meta/src/sql/studio/get-index-statuses.ts @@ -2,10 +2,9 @@ import { literal } from '../../pg-format' export const USER_SEARCH_INDEXES = [ 'idx_users_email', - 'idx_users_email_trgm', 'idx_users_created_at_desc', 'idx_users_last_sign_in_at_desc', - 'idx_users_name_trgm', + 'idx_users_name', // this index is not created by the indexworker but is required for efficient queries // it is already created as part of the `UNIQUE` constraint on the `phone` column 'users_phone_key', diff --git a/packages/pg-meta/src/sql/studio/get-users-paginated.ts b/packages/pg-meta/src/sql/studio/get-users-paginated.ts index cb184442607..9de39c62930 100644 --- a/packages/pg-meta/src/sql/studio/get-users-paginated.ts +++ b/packages/pg-meta/src/sql/studio/get-users-paginated.ts @@ -1,6 +1,11 @@ import { prefixToUUID, stringRange } from './get-users-common' import { OptimizedSearchColumns } from './get-users-types' +export interface UsersCursor { + sort: string + id: string +} + interface getPaginatedUsersSQLProps { page?: number verified?: 'verified' | 'unverified' | 'anonymous' @@ -13,6 +18,10 @@ interface getPaginatedUsersSQLProps { /** If set, uses fast queries but these don't allow any sorting so the above parameters are completely ignored. */ column?: OptimizedSearchColumns startAt?: string + + /** Cursor for cursor-based pagination (used by improved search) */ + cursor?: UsersCursor + improvedSearchEnabled?: boolean } const DEFAULT_LIMIT = 50 @@ -28,7 +37,23 @@ export const getPaginatedUsersSQL = ({ column, startAt, + cursor, + + improvedSearchEnabled = false, }: getPaginatedUsersSQLProps) => { + if (improvedSearchEnabled) { + return getImprovedPaginatedUsersSQL({ + column: column ?? 'email', + keywords, + verified, + providers, + sort, + order, + limit, + cursor, + }) + } + // IMPORTANT: DO NOT CHANGE THESE QUERIES EVEN IN THE SLIGHTEST WITHOUT CONSULTING WITH AUTH TEAM. const offset = page * limit const hasValidKeywords = keywords && keywords !== '' @@ -139,3 +164,151 @@ from return usersQuery } + +/** + * Generates SQL for improved paginated user search that leverages specific indexes. + * Uses cursor-based pagination for efficient and consistent paging. + * + * Indexes leveraged: + * - idx_users_email (btree) - for email prefix and exact match searches and sorting by email + * - idx_users_created_at_desc - for sorting by created_at + * - idx_users_last_sign_in_at_desc - for sorting by last_sign_in_at + * - idx_users_name (btree) - for name prefix and exact match searches on raw_user_meta_data->>'name' + * - users_phone_key (btree) - for phone prefix searches and sorting by phone + */ +export const getImprovedPaginatedUsersSQL = ({ + column, + keywords, + verified, + providers, + sort, + order, + cursor, + limit = DEFAULT_LIMIT, +}: getPaginatedUsersSQLProps) => { + const hasValidKeywords = keywords && keywords !== '' + const formattedKeywords = hasValidKeywords ? keywords.replaceAll("'", "''") : '' + + const conditions: string[] = [] + + // Column-specific search condition + if (hasValidKeywords) { + if (column === 'email') { + // Use btree index with prefix matching + const range = stringRange(formattedKeywords) + if (range[1]) { + conditions.push(`email >= '${range[0]}' AND email < '${range[1]}'`) + } else { + conditions.push(`email >= '${range[0]}'`) + } + } else if (column === 'phone') { + // Use btree index with prefix matching + const range = stringRange(formattedKeywords) + if (range[1]) { + conditions.push(`phone >= '${range[0]}' AND phone < '${range[1]}'`) + } else { + conditions.push(`phone >= '${range[0]}'`) + } + } else if (column === 'id') { + // Exact match on UUID + conditions.push(`id = '${formattedKeywords}'`) + } else if (column === 'name') { + // Use btree index with prefix matching on raw_user_meta_data->>'name' + const range = stringRange(formattedKeywords) + if (range[1]) { + conditions.push( + `raw_user_meta_data->>'name' >= '${range[0]}' AND raw_user_meta_data->>'name' < '${range[1]}'` + ) + } else { + conditions.push(`raw_user_meta_data->>'name' >= '${range[0]}'`) + } + } + } + + // Verified filter + if (verified === 'verified') { + conditions.push(`(email_confirmed_at IS NOT NULL OR phone_confirmed_at IS NOT NULL)`) + } else if (verified === 'anonymous') { + conditions.push(`is_anonymous IS TRUE`) + } else if (verified === 'unverified') { + conditions.push(`(email_confirmed_at IS NULL AND phone_confirmed_at IS NULL)`) + } + + // Providers filter + if (providers && providers.length > 0) { + if (providers.includes('saml 2.0')) { + conditions.push( + `(SELECT jsonb_agg(CASE WHEN value ~ '^sso' THEN 'sso' ELSE value END) FROM jsonb_array_elements_text((raw_app_meta_data ->> 'providers')::jsonb)) ?| array[${providers.map((p) => (p === 'saml 2.0' ? `'sso'` : `'${p}'`)).join(', ')}]` + ) + } else { + conditions.push( + `(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]` + ) + } + } + + const sortOn = sort ?? 'created_at' + const sortOrder = order ?? 'desc' + + // Cursor-based pagination: fetch rows after the cursor position + if (cursor) { + const operator = sortOrder === 'desc' ? '<' : '>' + // When sorting by id, no need for a composite cursor since id is already unique + if (sortOn === 'id') { + conditions.push(`id ${operator} '${cursor.id}'::uuid`) + } else { + conditions.push(`("${sortOn}", id) ${operator} ('${cursor.sort}', '${cursor.id}'::uuid)`) + } + } + + const combinedConditions = conditions.map((x) => `(${x})`).join(' AND ') + const whereClause = conditions.length > 0 ? `WHERE ${combinedConditions}` : '' + + // Order by sort column, with id as tie breaker (unless already sorting by id) + const orderByClause = + sortOn === 'id' ? `"${sortOn}" ${sortOrder}` : `"${sortOn}" ${sortOrder}, id ${sortOrder}` + + const usersData = ` + SELECT + auth.users.id, + auth.users.email, + auth.users.banned_until, + auth.users.created_at, + auth.users.confirmed_at, + auth.users.confirmation_sent_at, + auth.users.is_anonymous, + auth.users.is_sso_user, + auth.users.invited_at, + auth.users.last_sign_in_at, + auth.users.phone, + auth.users.raw_app_meta_data, + auth.users.raw_user_meta_data + FROM + auth.users + ${whereClause} + ORDER BY + ${orderByClause} + LIMIT + ${limit}` + + const usersQuery = ` +WITH + users_data AS (${usersData}) +SELECT + *, + COALESCE( + ( + SELECT + array_agg(DISTINCT i.provider) + FROM + auth.identities i + WHERE + i.user_id = users_data.id + ), + '{}'::text[] + ) AS providers +FROM + users_data;`.trim() + + return usersQuery +} diff --git a/packages/pg-meta/src/sql/studio/get-users-types.ts b/packages/pg-meta/src/sql/studio/get-users-types.ts index d0abed10de2..05da29316f8 100644 --- a/packages/pg-meta/src/sql/studio/get-users-types.ts +++ b/packages/pg-meta/src/sql/studio/get-users-types.ts @@ -1 +1 @@ -export type OptimizedSearchColumns = 'id' | 'email' | 'phone' +export type OptimizedSearchColumns = 'id' | 'email' | 'phone' | 'name'