mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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
This commit is contained in:
1 parent
621a8e1f34
commit
115ca3a2e3
9 files changed
+415
-185
No files matched your search
@@ -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 (
|
||||
<ButtonTooltip
|
||||
disabled
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-use
|
||||
|
||||
export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
|
||||
|
||||
export type SpecificFilterColumn = OptimizedSearchColumns | 'freeform'
|
||||
export type SpecificFilterColumn = OptimizedSearchColumns | 'name' | 'freeform'
|
||||
|
||||
export const UUIDV4_LEFT_PREFIX_REGEX =
|
||||
/^(?:[0-9a-f]{1,8}|[0-9a-f]{8}-|[0-9a-f]{8}-[0-9a-f]{1,4}|[0-9a-f]{8}-[0-9a-f]{4}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{0,3}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{0,3}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{0,12})$/i
|
||||
|
||||
@@ -149,6 +149,7 @@ export function getDisplayName(user: User, fallback = '-'): string {
|
||||
last_name,
|
||||
firstName,
|
||||
first_name,
|
||||
name,
|
||||
} = user.raw_user_meta_data ?? {}
|
||||
|
||||
const {
|
||||
@@ -193,7 +194,8 @@ export function getDisplayName(user: User, fallback = '-'): string {
|
||||
|
||||
return (
|
||||
toPrettyJsonString(
|
||||
displayName ||
|
||||
name ||
|
||||
displayName ||
|
||||
display_name ||
|
||||
ccDisplayName ||
|
||||
cc_display_name ||
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { SetStateAction } from 'react'
|
||||
|
||||
import { SpecificFilterColumn } from './Users.constants'
|
||||
|
||||
const getSearchPlaceholder = (column: SpecificFilterColumn): string => {
|
||||
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<string>) => 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 (
|
||||
<div className="flex items-center">
|
||||
@@ -61,21 +82,30 @@ export const UsersSearch = ({
|
||||
<SelectItem_Shadcn_ value="email" className="text-xs">
|
||||
Email address
|
||||
</SelectItem_Shadcn_>
|
||||
{improvedSearchEnabled && (
|
||||
<SelectItem_Shadcn_ value="name" className="text-xs">
|
||||
Name
|
||||
</SelectItem_Shadcn_>
|
||||
)}
|
||||
<SelectItem_Shadcn_ value="phone" className="text-xs">
|
||||
Phone number
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectSeparator_Shadcn_ />
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SelectItem_Shadcn_ value="freeform" className="text-xs">
|
||||
Unified search
|
||||
</SelectItem_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="w-64 text-center">
|
||||
Search by all columns at once, including mid-string search. May impact database
|
||||
performance if you have many users.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{!improvedSearchEnabled && (
|
||||
<>
|
||||
<SelectSeparator_Shadcn_ />
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SelectItem_Shadcn_ value="freeform" className="text-xs">
|
||||
Unified search
|
||||
</SelectItem_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="w-64 text-center">
|
||||
Search by all columns at once, including mid-string search. May impact database
|
||||
performance if you have many users.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
@@ -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())
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -122,16 +122,20 @@ export const UsersV2 = () => {
|
||||
|
||||
const [specificFilterColumn, setSpecificFilterColumn] = useQueryState<SpecificFilterColumn>(
|
||||
'filter',
|
||||
parseAsStringEnum<SpecificFilterColumn>(['id', 'email', 'phone', 'freeform']).withDefault(
|
||||
'email'
|
||||
)
|
||||
parseAsStringEnum<SpecificFilterColumn>([
|
||||
'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<SpecificFilterColumn>(
|
||||
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' && (
|
||||
<Select_Shadcn_
|
||||
value={filterUserType}
|
||||
onValueChange={(val) => {
|
||||
setFilterUserType(val as Filter)
|
||||
sendEvent({
|
||||
action: 'auth_users_search_submitted',
|
||||
properties: {
|
||||
trigger: 'user_type_filter',
|
||||
...telemetryProps,
|
||||
user_type: val,
|
||||
},
|
||||
groups: telemetryGroups,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger_Shadcn_
|
||||
size="tiny"
|
||||
className={cn(
|
||||
'w-[140px] !bg-transparent',
|
||||
filterUserType === 'all' && 'border-dashed'
|
||||
)}
|
||||
{showUserTypeFilter &&
|
||||
(specificFilterColumn === 'freeform' || improvedSearchEnabled) && (
|
||||
<Select_Shadcn_
|
||||
value={filterUserType}
|
||||
onValueChange={(val) => {
|
||||
setFilterUserType(val as Filter)
|
||||
sendEvent({
|
||||
action: 'auth_users_search_submitted',
|
||||
properties: {
|
||||
trigger: 'user_type_filter',
|
||||
...telemetryProps,
|
||||
user_type: val,
|
||||
},
|
||||
groups: telemetryGroups,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectValue_Shadcn_ />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="all" className="text-xs">
|
||||
All users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="verified" className="text-xs">
|
||||
Verified users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="unverified" className="text-xs">
|
||||
Unverified users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="anonymous" className="text-xs">
|
||||
Anonymous users
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
)}
|
||||
<SelectTrigger_Shadcn_
|
||||
size="tiny"
|
||||
className={cn(
|
||||
'w-[140px] !bg-transparent',
|
||||
filterUserType === 'all' && 'border-dashed'
|
||||
)}
|
||||
>
|
||||
<SelectValue_Shadcn_ />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="all" className="text-xs">
|
||||
All users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="verified" className="text-xs">
|
||||
Verified users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="unverified" className="text-xs">
|
||||
Unverified users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="anonymous" className="text-xs">
|
||||
Anonymous users
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
)}
|
||||
|
||||
{showProviderFilter && specificFilterColumn === 'freeform' && (
|
||||
<FilterPopover
|
||||
name="Provider"
|
||||
options={PROVIDER_FILTER_OPTIONS}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
iconKey="icon"
|
||||
activeOptions={selectedProviders}
|
||||
labelClass="text-xs"
|
||||
maxHeightClass="h-[190px]"
|
||||
className="w-52"
|
||||
onSaveFilters={(providers) => {
|
||||
setSelectedProviders(providers)
|
||||
sendEvent({
|
||||
action: 'auth_users_search_submitted',
|
||||
properties: {
|
||||
trigger: 'provider_filter',
|
||||
...telemetryProps,
|
||||
providers,
|
||||
},
|
||||
groups: telemetryGroups,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showProviderFilter &&
|
||||
(specificFilterColumn === 'freeform' || improvedSearchEnabled) && (
|
||||
<FilterPopover
|
||||
name="Provider"
|
||||
options={PROVIDER_FILTER_OPTIONS}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
iconKey="icon"
|
||||
activeOptions={selectedProviders}
|
||||
labelClass="text-xs"
|
||||
maxHeightClass="h-[190px]"
|
||||
className="w-52"
|
||||
onSaveFilters={(providers) => {
|
||||
setSelectedProviders(providers)
|
||||
sendEvent({
|
||||
action: 'auth_users_search_submitted',
|
||||
properties: {
|
||||
trigger: 'provider_filter',
|
||||
...telemetryProps,
|
||||
providers,
|
||||
},
|
||||
groups: telemetryGroups,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="border-r border-strong h-6" />
|
||||
|
||||
@@ -704,6 +721,7 @@ export const UsersV2 = () => {
|
||||
}}
|
||||
showSortByEmail={showSortByEmail}
|
||||
showSortByPhone={showSortByPhone}
|
||||
improvedSearchEnabled={improvedSearchEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = <TData = UsersData>(
|
||||
sort,
|
||||
order,
|
||||
column,
|
||||
|
||||
improvedSearchEnabled = false,
|
||||
}: UsersVariables,
|
||||
{
|
||||
enabled = true,
|
||||
@@ -48,7 +55,7 @@ export const useUsersInfiniteQuery = <TData = UsersData>(
|
||||
UsersError,
|
||||
InfiniteData<TData>,
|
||||
readonly unknown[],
|
||||
string | number | undefined
|
||||
string | number | UsersCursor | undefined
|
||||
> = {}
|
||||
) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
@@ -78,6 +85,8 @@ export const useUsersInfiniteQuery = <TData = UsersData>(
|
||||
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 = <TData = UsersData>(
|
||||
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<OptimizedSearchColumns, 'name'>]
|
||||
} else {
|
||||
const page = pages.length
|
||||
if (!hasNextPage) return undefined
|
||||
return page
|
||||
return pages.length
|
||||
}
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export type OptimizedSearchColumns = 'id' | 'email' | 'phone'
|
||||
export type OptimizedSearchColumns = 'id' | 'email' | 'phone' | 'name'
|
||||
Reference in new issue
Block a user