Files
supabase/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx
CharisandJoshen Lim 271ee3af6d fix: estimate number of users when count is large (#38638)
* fix: estimate number of users when count is large

In the Auth Users table, we always fetch an exact count of users. This
can be a problem for projects with many (>50K) users as the count(*)
might cause performance issues on the database. We already have logic on
the Table Editor to only run automatic count estimates (fetching the
exact count only if usr requests it), this change ports the same logic
over to Auth Users.

* Nit refactor

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2025-09-12 13:07:31 +08:00

709 lines
28 KiB
TypeScript

import { useQueryClient } from '@tanstack/react-query'
import AwesomeDebouncePromise from 'awesome-debounce-promise'
import {
ArrowDown,
ArrowUp,
HelpCircle,
Loader2,
RefreshCw,
Search,
Trash,
Users,
X,
} from 'lucide-react'
import { UIEvent, useEffect, useMemo, useRef, useState } from 'react'
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
import { toast } from 'sonner'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import AlertError from 'components/ui/AlertError'
import { APIDocsButton } from 'components/ui/APIDocsButton'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FilterPopover } from 'components/ui/FilterPopover'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { authKeys } from 'data/auth/keys'
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
import { useUsersCountQuery } from 'data/auth/users-count-query'
import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
import { THRESHOLD_COUNT } from 'data/table-rows/table-rows-count-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { isAtBottom } from 'lib/helpers'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
LoadingLine,
ResizablePanel,
ResizablePanelGroup,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { AddUserDropdown } from './AddUserDropdown'
import { DeleteUserModal } from './DeleteUserModal'
import { UserPanel } from './UserPanel'
import {
ColumnConfiguration,
MAX_BULK_DELETE,
PROVIDER_FILTER_OPTIONS,
USERS_TABLE_COLUMNS,
} from './Users.constants'
import { formatUserColumns, formatUsersData } from './Users.utils'
export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
// [Joshen] Just naming it as V2 as its a rewrite of the old one, to make it easier for reviews
// Can change it to remove V2 thereafter
export const UsersV2 = () => {
const queryClient = useQueryClient()
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const gridRef = useRef<DataGridHandle>(null)
const xScroll = useRef<number>(0)
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const {
authenticationShowProviderFilter: showProviderFilter,
authenticationShowSortByEmail: showSortByEmail,
authenticationShowSortByPhone: showSortByPhone,
authenticationShowUserTypeFilter: showUserTypeFilter,
} = useIsFeatureEnabled([
'authentication:show_provider_filter',
'authentication:show_sort_by_email',
'authentication:show_sort_by_phone',
'authentication:show_user_type_filter',
])
const [columns, setColumns] = useState<Column<any>[]>([])
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [filterKeywords, setFilterKeywords] = useState('')
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
const [sortByValue, setSortByValue] = useState<string>('created_at:desc')
const [selectedUser, setSelectedUser] = useState<string>()
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
const [selectedUserToDelete, setSelectedUserToDelete] = useState<User>()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isDeletingUsers, setIsDeletingUsers] = useState(false)
const [forceExactCount, setForceExactCount] = useState(false)
const [showFetchExactCountModal, setShowFetchExactCountModal] = useState(false)
const [
columnConfiguration,
setColumnConfiguration,
{ isSuccess: isSuccessStorage, isError: isErrorStorage, error: errorStorage },
] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.AUTH_USERS_COLUMNS_CONFIGURATION(projectRef ?? ''),
null as ColumnConfiguration[] | null
)
const [sortColumn, sortOrder] = sortByValue.split(':')
const {
data,
error,
isSuccess,
isLoading,
isRefetching,
isError,
isFetchingNextPage,
refetch,
hasNextPage,
fetchNextPage,
} = useUsersInfiniteQuery(
{
projectRef,
connectionString: project?.connectionString,
keywords: filterKeywords,
filter: filter === 'all' ? undefined : filter,
providers: selectedProviders,
sort: sortColumn as 'created_at' | 'email' | 'phone',
order: sortOrder as 'asc' | 'desc',
},
{
keepPreviousData: Boolean(filterKeywords),
// [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,
}
)
const {
data: countData,
refetch: refetchCount,
isLoading: isLoadingCount,
} = useUsersCountQuery({
projectRef,
connectionString: project?.connectionString,
keywords: filterKeywords,
filter: filter === 'all' ? undefined : filter,
providers: selectedProviders,
forceExactCount,
})
const { mutateAsync: deleteUser } = useUserDeleteMutation()
const totalUsers = countData?.count ?? 0
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
// [Joshen] Only relevant for when selecting one user only
const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0])
const formatEstimatedCount = (count: number) => {
if (count >= 1e6) return `${(count / 1e6).toFixed(1)}M`
if (count >= 1e3) return `${(count / 1e3).toFixed(1)}K`
return count.toString()
}
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft
xScroll.current = event.currentTarget.scrollLeft
if (
isLoading ||
isFetchingNextPage ||
isScrollingHorizontally ||
!isAtBottom(event) ||
!hasNextPage
) {
return
}
fetchNextPage()
}
const clearSearch = () => {
setSearch('')
setFilterKeywords('')
}
const swapColumns = (data: any[], sourceIdx: number, targetIdx: number) => {
const updatedColumns = data.slice()
const [removed] = updatedColumns.splice(sourceIdx, 1)
updatedColumns.splice(targetIdx, 0, removed)
return updatedColumns
}
// [Joshen] Left off here - it's tricky trying to do both column toggling and re-ordering
const saveColumnConfiguration = AwesomeDebouncePromise(
(event: 'resize' | 'reorder' | 'toggle', value) => {
if (event === 'toggle') {
const columnConfig = value.columns.map((col: any) => ({
id: col.key,
width: col.width,
}))
setColumnConfiguration(columnConfig)
} else if (event === 'resize') {
const columnConfig = columns.map((col, idx) => ({
id: col.key,
width: idx === value.idx ? value.width : col.width,
}))
setColumnConfiguration(columnConfig)
} else if (event === 'reorder') {
const columnConfig = value.columns.map((col: any) => ({
id: col.key,
width: col.width,
}))
setColumnConfiguration(columnConfig)
}
},
500
)
const handleDeleteUsers = async () => {
if (!projectRef) return console.error('Project ref is required')
const userIds = [...selectedUsers]
setIsDeletingUsers(true)
try {
await Promise.all(
userIds.map((id) => deleteUser({ projectRef, userId: id, skipInvalidation: true }))
)
// [Joshen] Skip invalidation within RQ to prevent multiple requests, then invalidate once at the end
await Promise.all([
queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
])
toast.success(
`Successfully deleted the selected ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`
)
setShowDeleteModal(false)
setSelectedUsers(new Set([]))
if (userIds.includes(selectedUser)) setSelectedUser(undefined)
} catch (error: any) {
toast.error(`Failed to delete selected users: ${error.message}`)
} finally {
setIsDeletingUsers(false)
}
}
useEffect(() => {
if (
!isRefetching &&
(isSuccessStorage ||
(isErrorStorage && (errorStorage as Error).message.includes('data is undefined')))
) {
const columns = formatUserColumns({
config: columnConfiguration ?? [],
users: users ?? [],
visibleColumns: selectedColumns,
setSortByValue,
onSelectDeleteUser: setSelectedUserToDelete,
})
setColumns(columns)
if (columns.length < USERS_TABLE_COLUMNS.length) {
setSelectedColumns(columns.filter((col) => col.key !== 'img').map((col) => col.key))
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isSuccess,
isRefetching,
isSuccessStorage,
isErrorStorage,
errorStorage,
users,
selectedUsers,
])
return (
<>
<div className="h-full flex flex-col">
<FormHeader className="py-4 px-6 !mb-0" title="Users" />
<div className="bg-surface-200 py-3 px-4 md:px-6 flex flex-col lg:flex-row lg:items-center justify-between gap-2 border-t">
{selectedUsers.size > 0 ? (
<div className="flex items-center gap-x-2">
<Button type="default" icon={<Trash />} onClick={() => setShowDeleteModal(true)}>
Delete {selectedUsers.size} users
</Button>
<ButtonTooltip
type="default"
icon={<X />}
className="px-1.5"
onClick={() => setSelectedUsers(new Set([]))}
tooltip={{ content: { side: 'bottom', text: 'Cancel selection' } }}
/>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
size="tiny"
className="w-52 pl-7 bg-transparent"
iconContainerClassName="pl-2"
icon={<Search size={14} className="text-foreground-lighter" />}
placeholder="Search email, phone or UID"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.code === 'Enter') {
setSearch(search.trim())
setFilterKeywords(search.trim().toLocaleLowerCase())
}
}}
actions={[
search && (
<Button
size="tiny"
type="text"
icon={<X />}
onClick={() => clearSearch()}
className="p-0 h-5 w-5"
/>
),
]}
/>
{showUserTypeFilter && (
<Select_Shadcn_ value={filter} onValueChange={(val) => setFilter(val as Filter)}>
<SelectTrigger_Shadcn_
size="tiny"
className={cn(
'w-[140px] !bg-transparent',
filter === '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 && (
<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={setSelectedProviders}
/>
)}
<div className="border-r border-strong h-6" />
<FilterPopover
name={selectedColumns.length === 0 ? 'All columns' : 'Columns'}
title="Select columns to show"
buttonType={selectedColumns.length === 0 ? 'dashed' : 'default'}
options={USERS_TABLE_COLUMNS.slice(1)} // Ignore user image column
labelKey="name"
valueKey="id"
labelClass="text-xs"
maxHeightClass="h-[190px]"
clearButtonText="Reset"
activeOptions={selectedColumns}
onSaveFilters={(value) => {
// When adding back hidden columns:
// (1) width set to default value if any
// (2) they will just get appended to the end
// (3) If "clearing", reset order of the columns to original
let updatedConfig = (columnConfiguration ?? []).slice()
if (value.length === 0) {
updatedConfig = USERS_TABLE_COLUMNS.map((c) => ({ id: c.id, width: c.width }))
} else {
value.forEach((col) => {
const hasExisting = updatedConfig.find((c) => c.id === col)
if (!hasExisting)
updatedConfig.push({
id: col,
width: USERS_TABLE_COLUMNS.find((c) => c.id === col)?.width,
})
})
}
const updatedColumns = formatUserColumns({
config: updatedConfig,
users: users ?? [],
visibleColumns: value,
setSortByValue,
onSelectDeleteUser: setSelectedUserToDelete,
})
setSelectedColumns(value)
setColumns(updatedColumns)
saveColumnConfiguration('toggle', { columns: updatedColumns })
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={sortOrder === 'desc' ? <ArrowDown /> : <ArrowUp />}>
Sorted by {sortColumn.replaceAll('_', ' ')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44" align="start">
<DropdownMenuRadioGroup value={sortByValue} onValueChange={setSortByValue}>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by created at</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="created_at:asc">
Ascending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_at:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by last sign in at</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="last_sign_in_at:asc">
Ascending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="last_sign_in_at:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{showSortByEmail && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by email</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="email:asc">
Ascending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="email:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{showSortByPhone && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by phone</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="phone:asc">
Ascending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="phone:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-x-2">
{isNewAPIDocsEnabled && <APIDocsButton section={['user-management']} />}
<Button
size="tiny"
icon={<RefreshCw />}
type="default"
loading={isRefetching && !isFetchingNextPage}
onClick={() => {
refetch()
refetchCount()
}}
>
Refresh
</Button>
<AddUserDropdown />
</div>
</>
)}
</div>
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
<ResizablePanelGroup
direction="horizontal"
className="relative flex flex-grow bg-alternative min-h-0"
autoSaveId="query-performance-layout-v1"
>
<ResizablePanel defaultSize={1}>
<div className="flex flex-col w-full h-full">
<DataGrid
ref={gridRef}
className="flex-grow border-t-0"
rowHeight={44}
headerRowHeight={36}
columns={columns}
rows={formatUsersData(users ?? [])}
rowClass={(row) => {
const isSelected = row.id === selectedUser
return [
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
'[&>.rdg-cell:first-child>div]:ml-4',
].join(' ')
}}
rowKeyGetter={(row) => row.id}
selectedRows={selectedUsers}
onScroll={handleScroll}
onSelectedRowsChange={(rows) => {
if (rows.size > MAX_BULK_DELETE) {
toast(`Only up to ${MAX_BULK_DELETE} users can be selected at a time`)
} else setSelectedUsers(rows)
}}
onColumnResize={(idx, width) => saveColumnConfiguration('resize', { idx, width })}
onColumnsReorder={(source, target) => {
const sourceIdx = columns.findIndex((col) => col.key === source)
const targetIdx = columns.findIndex((col) => col.key === target)
const updatedColumns = swapColumns(columns, sourceIdx, targetIdx)
setColumns(updatedColumns)
saveColumnConfiguration('reorder', { columns: updatedColumns })
}}
renderers={{
renderRow(id, props) {
return (
<Row
{...props}
onClick={() => {
const user = users.find((u) => u.id === id)
if (user) {
const idx = users.indexOf(user)
if (props.row.id) {
setSelectedUser(props.row.id)
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
}
}
}}
/>
)
},
noRowsFallback: isLoading ? (
<div className="absolute top-14 px-6 w-full">
<GenericSkeletonLoader />
</div>
) : isError ? (
<div className="absolute top-14 px-6 flex flex-col items-center justify-center w-full">
<AlertError subject="Failed to retrieve users" error={error} />
</div>
) : (
<div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<Users className="text-foreground-lighter" strokeWidth={1} />
<div className="text-center">
<p className="text-foreground">
{filter !== 'all' || filterKeywords.length > 0
? 'No users found'
: 'No users in your project'}
</p>
<p className="text-foreground-light">
{filter !== 'all' || filterKeywords.length > 0
? 'There are currently no users based on the filters applied'
: 'There are currently no users who signed up to your project'}
</p>
</div>
</div>
),
}}
/>
</div>
</ResizablePanel>
{selectedUser !== undefined && (
<UserPanel
selectedUser={users.find((u) => u.id === selectedUser)}
onClose={() => setSelectedUser(undefined)}
/>
)}
</ResizablePanelGroup>
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
<div className="flex items-center gap-x-2">
{isLoadingCount ? (
'Loading user count...'
) : (
<>
<span>
Total:{' '}
{countData?.is_estimate
? formatEstimatedCount(totalUsers)
: totalUsers.toLocaleString()}{' '}
user{totalUsers !== 1 ? 's' : ''}
{countData?.is_estimate && ' (estimated)'}
</span>
{countData?.is_estimate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="tiny"
type="text"
className="px-1.5"
icon={<HelpCircle />}
onClick={() => {
setShowFetchExactCountModal(true)
}}
/>
</TooltipTrigger>
<TooltipContent side="top" className="w-72">
This is an estimated value as your project has more than{' '}
{THRESHOLD_COUNT.toLocaleString()} users.
<br />
<span className="text-brand">Click to retrieve the exact count.</span>
</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
{(isLoading || isRefetching || isFetchingNextPage) && (
<span className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" /> Loading...
</span>
)}
</div>
</div>
<ConfirmationModal
visible={showDeleteModal}
variant="destructive"
title={`Confirm to delete ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`}
loading={isDeletingUsers}
confirmLabel="Delete"
onCancel={() => setShowDeleteModal(false)}
onConfirm={() => handleDeleteUsers()}
alert={{
title: `Deleting ${selectedUsers.size === 1 ? 'a user' : 'users'} is irreversible`,
description: `This will remove the selected ${selectedUsers.size === 1 ? '' : `${selectedUsers.size} `}user${selectedUsers.size > 1 ? 's' : ''} from the project and all associated data.`,
}}
>
<p className="text-sm text-foreground-light">
This is permanent! Are you sure you want to delete the{' '}
{selectedUsers.size === 1 ? '' : `selected ${selectedUsers.size} `}user
{selectedUsers.size > 1 ? 's' : ''}
{selectedUsers.size === 1 ? (
<span className="text-foreground">
{' '}
{selectedUserFromCheckbox?.email ?? selectedUserFromCheckbox?.phone ?? 'this user'}
</span>
) : null}
?
</p>
</ConfirmationModal>
{/* [Joshen] For deleting via context menu, the dialog above is dependent on the selectedUsers state */}
<DeleteUserModal
visible={!!selectedUserToDelete}
selectedUser={selectedUserToDelete}
onClose={() => setSelectedUserToDelete(undefined)}
onDeleteSuccess={() => {
if (selectedUserToDelete?.id === selectedUser) setSelectedUser(undefined)
setSelectedUserToDelete(undefined)
}}
/>
<ConfirmationModal
variant="warning"
visible={showFetchExactCountModal}
title="Fetch exact user count"
confirmLabel="Fetch exact count"
onCancel={() => setShowFetchExactCountModal(false)}
onConfirm={() => {
setForceExactCount(true)
setShowFetchExactCountModal(false)
}}
>
<p className="text-sm text-foreground-light">
Your project has more than {THRESHOLD_COUNT.toLocaleString()} users, and fetching the
exact count may cause performance issues on your database.
</p>
</ConfirmationModal>
</>
)
}