import { useQueryClient } from '@tanstack/react-query' import AwesomeDebouncePromise from 'awesome-debounce-promise' import { ArrowDown, ArrowUp, 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 { 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_, } 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(null) const xScroll = useRef(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[]>([]) const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') const [filterKeywords, setFilterKeywords] = useState('') const [selectedColumns, setSelectedColumns] = useState([]) const [selectedProviders, setSelectedProviders] = useState([]) const [sortByValue, setSortByValue] = useState('created_at:desc') const [selectedUser, setSelectedUser] = useState() const [selectedUsers, setSelectedUsers] = useState>(new Set([])) const [selectedUserToDelete, setSelectedUserToDelete] = useState() const [showDeleteModal, setShowDeleteModal] = useState(false) const [isDeletingUsers, setIsDeletingUsers] = 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 } = useUsersCountQuery({ projectRef, connectionString: project?.connectionString, keywords: filterKeywords, filter: filter === 'all' ? undefined : filter, providers: selectedProviders, }) const { mutateAsync: deleteUser } = useUserDeleteMutation() const totalUsers = countData ?? 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 handleScroll = (event: UIEvent) => { 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 ( <>
{selectedUsers.size > 0 ? (
} className="px-1.5" onClick={() => setSelectedUsers(new Set([]))} tooltip={{ content: { side: 'bottom', text: 'Cancel selection' } }} />
) : ( <>
} 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 && ( Sort by created at Ascending Descending Sort by last sign in at Ascending Descending {showSortByEmail && ( Sort by email Ascending Descending )} {showSortByPhone && ( Sort by phone Ascending Descending )}
{isNewAPIDocsEnabled && }
)}
{ 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 ( { 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 ? (
) : isError ? (
) : (

{filter !== 'all' || filterKeywords.length > 0 ? 'No users found' : 'No users in your project'}

{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'}

), }} />
{selectedUser !== undefined && ( u.id === selectedUser)} onClose={() => setSelectedUser(undefined)} /> )}
{isLoading || isRefetching ? 'Loading users...' : `Total: ${totalUsers} users`} {(isLoading || isRefetching || isFetchingNextPage) && ( Loading... )}
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.`, }} >

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 ? ( {' '} {selectedUserFromCheckbox?.email ?? selectedUserFromCheckbox?.phone ?? 'this user'} ) : null} ?

{/* [Joshen] For deleting via context menu, the dialog above is dependent on the selectedUsers state */} setSelectedUserToDelete(undefined)} onDeleteSuccess={() => { if (selectedUserToDelete?.id === selectedUser) setSelectedUser(undefined) setSelectedUserToDelete(undefined) }} /> ) }