From 115ca3a2e3263aca0d3047766672b64d990a0e6e Mon Sep 17 00:00:00 2001 From: issuedat <165281975+issuedat@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:24:43 +0100 Subject: [PATCH 1/2] feat(auth): improved user search (#41199) * feat(auth): improved user search * chore: fix types for columns * feat: cursor-based pagination * chore: remove trigram indexes in favour of b-tree --- .../interfaces/Auth/Users/SortDropdown.tsx | 4 +- .../interfaces/Auth/Users/Users.constants.ts | 2 +- .../interfaces/Auth/Users/Users.utils.tsx | 4 +- .../interfaces/Auth/Users/UsersSearch.tsx | 71 ++-- .../interfaces/Auth/Users/UsersV2.tsx | 310 +++++++++--------- apps/studio/data/auth/users-infinite-query.ts | 33 +- .../src/sql/studio/get-index-statuses.ts | 3 +- .../src/sql/studio/get-users-paginated.ts | 173 ++++++++++ .../pg-meta/src/sql/studio/get-users-types.ts | 2 +- 9 files changed, 416 insertions(+), 186 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx index 1ab879906d4aa..900e42ee314a7 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 277fc6c35a88a..4ff3b6671d132 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 - - - - - )} - - {showProviderFilter && specificFilterColumn === 'freeform' && ( - { - setSelectedProviders(providers) - sendEvent({ - action: 'auth_users_search_submitted', - properties: { - trigger: 'provider_filter', - ...telemetryProps, - providers, - }, - groups: telemetryGroups, - }) - }} - /> - )} + + + + + + + All users + + + Verified users + + + Unverified users + + + Anonymous users + + + + + )} + + {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 c924b7d36d19b..0db05a99c2e30 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 cd50dc0fd90e7..8b09717270582 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 cb18444260721..9de39c6293001 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 d0abed10de287..05da29316f874 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' From db4b10f328332ef5c4c9bade3cf5ad1b1938da25 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 24 Dec 2025 17:18:29 +0200 Subject: [PATCH 2/2] fix: Check the password strength before creating a project (#41586) * Fix types and lint warnings for the password strength function. * Simplify the form for creating project. Move the password warning into the form schema. Minor fixes. * Fix the name of the field. * Move the common behaviour in a function. * Minor fixes. --- .../CreateNewProjectDialog.tsx | 6 +- .../ProjectCreation/DatabasePasswordInput.tsx | 52 ++++++------- .../ProjectCreation/ProjectCreation.schema.ts | 74 +++++++++++-------- .../DatabaseSettings/ResetDbPassword.tsx | 8 +- .../components/ui/PasswordStrengthBar.tsx | 17 ++--- apps/studio/lib/constants/infrastructure.ts | 10 +-- apps/studio/lib/password-strength.ts | 6 +- .../[slug]/deploy-button/new-project.tsx | 6 +- apps/studio/pages/new/[slug].tsx | 41 +++------- 9 files changed, 108 insertions(+), 112 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx index e62e335673a8f..a57a29ef73829 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx @@ -4,12 +4,12 @@ import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' -import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { PasswordStrengthBar } from 'components/ui/PasswordStrengthBar' import { useProjectCloneMutation } from 'data/projects/clone-mutation' import { useCloneBackupsQuery } from 'data/projects/clone-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { passwordStrength } from 'lib/password-strength' +import { passwordStrength, PasswordStrengthScore } from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' import { Button, @@ -171,7 +171,7 @@ export const CreateNewProjectDialog = ({ }} descriptionText={ - passwordStrengthMessage: string - setPasswordStrengthMessage: (value: string) => void - setPasswordStrengthWarning: (value: string) => void } -export const DatabasePasswordInput = ({ - form, - passwordStrengthMessage, - setPasswordStrengthMessage, - setPasswordStrengthWarning, -}: DatabasePasswordInputProps) => { - async function checkPasswordStrength(value: any) { - const { message, warning, strength } = await passwordStrength(value) +const updatePasswordStrength = async (form: UseFormReturn, value: string) => { + try { + const { warning, message, strength } = await passwordStrength(value) + form.setValue('dbPassStrength', strength, { shouldValidate: false, shouldDirty: false }) + form.setValue('dbPassStrengthMessage', message ?? '', { + shouldValidate: false, + shouldDirty: false, + }) + form.setValue('dbPassStrengthWarning', warning ?? '', { + shouldValidate: false, + shouldDirty: false, + }) - form.setValue('dbPassStrength', strength) - form.trigger('dbPassStrength') form.trigger('dbPass') - - setPasswordStrengthWarning(warning) - setPasswordStrengthMessage(message) + } catch (error) { + console.error(error) } +} +export const DatabasePasswordInput = ({ form }: DatabasePasswordInputProps) => { // [Refactor] DB Password could be a common component used in multiple pages with repeated logic - function generatePassword() { + async function generatePassword() { const password = generateStrongPassword() form.setValue('dbPass', password) - checkPasswordStrength(password) + + updatePasswordStrength(form, password) } return ( @@ -61,7 +62,7 @@ export const DatabasePasswordInput = ({ @@ -75,15 +76,10 @@ export const DatabasePasswordInput = ({ {...field} autoComplete="off" onChange={async (event) => { + const newValue = event.target.value field.onChange(event) - form.trigger('dbPassStrength') - const value = event.target.value - if (event.target.value === '') { - await form.setValue('dbPassStrength', 0) - await form.trigger('dbPass') - } else { - await checkPasswordStrength(value) - } + + updatePasswordStrength(form, newValue) }} /> diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts index 334050a692813..d2e25ceb26614 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts @@ -1,33 +1,49 @@ +import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants' import { z } from 'zod' -export const FormSchema = z.object({ - organization: z.string({ - required_error: 'Please select an organization', - }), - projectName: z - .string() - .trim() - .min(1, 'Please enter a project name.') // Required field check - .min(3, 'Project name must be at least 3 characters long.') // Minimum length check - .max(64, 'Project name must be no longer than 64 characters.'), // Maximum length check - postgresVersion: z.string({ - required_error: 'Please enter a Postgres version.', - }), - dbRegion: z.string({ - required_error: 'Please select a region.', - }), - cloudProvider: z.string({ - required_error: 'Please select a cloud provider.', - }), - dbPassStrength: z.number(), - dbPass: z - .string({ required_error: 'Please enter a database password.' }) - .min(1, 'Password is required.'), - instanceSize: z.string().optional(), - dataApi: z.boolean(), - useApiSchema: z.boolean(), - postgresVersionSelection: z.string(), - useOrioleDb: z.boolean(), -}) +export const FormSchema = z + .object({ + organization: z.string({ + required_error: 'Please select an organization', + }), + projectName: z + .string() + .trim() + .min(1, 'Please enter a project name.') // Required field check + .min(3, 'Project name must be at least 3 characters long.') // Minimum length check + .max(64, 'Project name must be no longer than 64 characters.'), // Maximum length check + postgresVersion: z.string({ + required_error: 'Please enter a Postgres version.', + }), + dbRegion: z.string({ + required_error: 'Please select a region.', + }), + cloudProvider: z.string({ + required_error: 'Please select a cloud provider.', + }), + + dbPass: z + .string({ required_error: 'Please enter a database password.' }) + .min(1, 'Password is required.'), + dbPassStrength: z + .union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]) + .default(0), + dbPassStrengthMessage: z.string().default(''), + dbPassStrengthWarning: z.string().default(''), + instanceSize: z.string().optional(), + dataApi: z.boolean(), + useApiSchema: z.boolean(), + postgresVersionSelection: z.string(), + useOrioleDb: z.boolean(), + }) + .superRefine(({ dbPassStrength, dbPassStrengthWarning }, ctx) => { + if (dbPassStrength < DEFAULT_MINIMUM_PASSWORD_STRENGTH) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['dbPass'], + message: dbPassStrengthWarning || 'Password not secure enough', + }) + } + }) export type CreateProjectForm = z.infer diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 9be22d528c11d..47cd9883c2baf 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -5,12 +5,12 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { PasswordStrengthBar } from 'components/ui/PasswordStrengthBar' import { useDatabasePasswordResetMutation } from 'data/database/database-password-reset-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants' -import { passwordStrength } from 'lib/password-strength' +import { passwordStrength, PasswordStrengthScore } from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' import { Button, Card, CardContent, Input, Modal } from 'ui' import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' @@ -35,7 +35,7 @@ const ResetDbPassword = ({ disabled = false }) => { const [password, setPassword] = useState('') const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('') const [passwordStrengthWarning, setPasswordStrengthWarning] = useState('') - const [passwordStrengthScore, setPasswordStrengthScore] = useState(0) + const [passwordStrengthScore, setPasswordStrengthScore] = useState(0) const { mutate: resetDatabasePassword, isPending: isUpdatingPassword } = useDatabasePasswordResetMutation({ @@ -134,7 +134,7 @@ const ResetDbPassword = ({ disabled = false }) => { // @ts-ignore descriptionText={ void } -const PasswordStrengthBar = ({ +export const PasswordStrengthBar = ({ passwordStrengthScore = 0, passwordStrengthMessage = '', password = '', @@ -20,18 +21,16 @@ const PasswordStrengthBar = ({
)} @@ -48,5 +47,3 @@ const PasswordStrengthBar = ({ ) } - -export default PasswordStrengthBar diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index d9343193b46cc..8c939c3ff6657 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -118,11 +118,11 @@ export const PASSWORD_STRENGTH_COLOR = { } export const PASSWORD_STRENGTH_PERCENTAGE = { - 0: '10%', - 1: '30%', - 2: '50%', - 3: '80%', - 4: '100%', + 0: 10, + 1: 30, + 2: 50, + 3: 80, + 4: 100, } export const DEFAULT_PROJECT_API_SERVICE_ID = 1 diff --git a/apps/studio/lib/password-strength.ts b/apps/studio/lib/password-strength.ts index 5dd1502e5ce5a..39a34cc44aec9 100644 --- a/apps/studio/lib/password-strength.ts +++ b/apps/studio/lib/password-strength.ts @@ -1,12 +1,16 @@ import { DEFAULT_MINIMUM_PASSWORD_STRENGTH, PASSWORD_STRENGTH } from 'lib/constants' +// This is the same as the ZXCVBNScore type from zxcvbn +// but we need to define it here because we don't to import zxcvbn everywhere +export type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4 + export async function passwordStrength(value: string) { // [Alaister]: Lazy load zxcvbn to avoid bundling it with the main app (it's pretty chunky) const zxcvbn = await import('zxcvbn').then((module) => module.default) let message: string = '' let warning: string = '' - let strength: number = 0 + let strength: PasswordStrengthScore = 0 if (value && value !== '') { if (value.length > 99) { diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 44aa1cef64b6f..6bebe8d930929 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -7,7 +7,7 @@ import { isVercelUrl } from 'components/interfaces/Integrations/Vercel/VercelInt import { Markdown } from 'components/interfaces/Markdown' import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' import { ScaffoldColumn, ScaffoldContainer } from 'components/layouts/Scaffold' -import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { PasswordStrengthBar } from 'components/ui/PasswordStrengthBar' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useIntegrationsQuery } from 'data/integrations/integrations-query' import { useIntegrationVercelConnectionsCreateMutation } from 'data/integrations/integrations-vercel-connections-create-mutation' @@ -17,7 +17,7 @@ import { useProjectCreateMutation } from 'data/projects/project-create-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, PROVIDERS } from 'lib/constants' import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils' -import { passwordStrength } from 'lib/password-strength' +import { passwordStrength, PasswordStrengthScore } from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' import { AWS_REGIONS } from 'shared-data' import { useIntegrationInstallationSnapshot } from 'state/integration-installation' @@ -232,7 +232,7 @@ const CreateProject = () => { onChange={onDbPassChange} descriptionText={ { } const [allProjects, setAllProjects] = useState(undefined) - const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('') - const [passwordStrengthWarning, setPasswordStrengthWarning] = useState('') const [isComputeCostsConfirmationModalVisible, setIsComputeCostsConfirmationModalVisible] = useState(false) - FormSchema.superRefine(({ dbPassStrength }, refinementContext) => { - if (dbPassStrength < DEFAULT_MINIMUM_PASSWORD_STRENGTH) { - refinementContext.addIssue({ - code: z.ZodIssueCode.custom, - path: ['dbPass'], - message: passwordStrengthWarning || 'Password not secure enough', - }) - } - }) - const form = useForm>({ resolver: zodResolver(FormSchema), mode: 'onChange', @@ -128,6 +110,7 @@ const Wizard: NextPageWithLayout = () => { cloudProvider: PROVIDERS[defaultProvider].id, dbPass: '', dbPassStrength: 0, + dbPassStrengthMessage: '', dbRegion: undefined, instanceSize: canChooseInstanceSize ? sizes[0] : undefined, dataApi: true, @@ -136,7 +119,12 @@ const Wizard: NextPageWithLayout = () => { useOrioleDb: false, }, }) - const { instanceSize: watchedInstanceSize, cloudProvider, dbRegion, organization } = form.watch() + const { + instanceSize: watchedInstanceSize, + cloudProvider, + dbRegion, + organization, + } = useWatch_Shadcn_({ control: form.control }) // [Charis] Since the form is updated in a useEffect, there is an edge case // when switching from free to paid, where canChooseInstanceSize is true for @@ -224,12 +212,12 @@ const Wizard: NextPageWithLayout = () => { const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices - const dbRegionExact = smartRegionToExactRegion(dbRegion) + const dbRegionExact = smartRegionToExactRegion(dbRegion ?? '') const availableOrioleVersion = useAvailableOrioleImageVersion( { cloudProvider: cloudProvider as CloudProvider, - dbRegion: smartRegionEnabled ? dbRegionExact : dbRegion, + dbRegion: smartRegionEnabled ? dbRegionExact : dbRegion ?? '', organizationSlug: organization, }, { enabled: currentOrg !== null } @@ -420,12 +408,7 @@ const Wizard: NextPageWithLayout = () => { {canChooseInstanceSize && } - +