mirror of
https://github.com/supabase/supabase.git
synced 2026-06-12 17:27:58 +08:00
## Screenshots Before: <img width="856" height="1140" alt="image" src="https://github.com/user-attachments/assets/d1e04a6f-d3ed-43b3-84f1-3000f2ecac17" /> After: <img width="398" height="107" alt="image" src="https://github.com/user-attachments/assets/6b41663b-31ee-4556-b2e4-6847f705aa38" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Style** * Increased CSS specificity in the Users view to reliably remove the top border, ensuring consistent visual rendering across layouts and themes. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46413?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
987 lines
38 KiB
TypeScript
987 lines
38 KiB
TypeScript
import type { OptimizedSearchColumns } from '@supabase/pg-meta'
|
|
import { USER_SEARCH_INDEXES } from '@supabase/pg-meta'
|
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
|
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
|
import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
|
|
import {
|
|
ExternalLinkIcon,
|
|
InfoIcon,
|
|
RefreshCw,
|
|
Trash,
|
|
Users,
|
|
WandSparklesIcon,
|
|
X,
|
|
} from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'
|
|
import { UIEvent, useEffect, useMemo, useRef, useState } from 'react'
|
|
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
Button,
|
|
cn,
|
|
LoadingLine,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from 'ui'
|
|
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
|
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import { AddUserDropdown } from './AddUserDropdown'
|
|
import { DeleteUserModal } from './DeleteUserModal'
|
|
import { SortDropdown } from './SortDropdown'
|
|
import { useAuthUsersShortcuts } from './useAuthUsersShortcuts'
|
|
import { UserPanel } from './UserPanel'
|
|
import type { SpecificFilterColumn } from './Users.constants'
|
|
import {
|
|
ColumnConfiguration,
|
|
Filter,
|
|
MAX_BULK_DELETE,
|
|
PROVIDER_FILTER_OPTIONS,
|
|
USERS_TABLE_COLUMNS,
|
|
} from './Users.constants'
|
|
import { formatUserColumns, formatUsersData } from './Users.utils'
|
|
import { UsersFooter } from './UsersFooter'
|
|
import { UsersSearch } from './UsersSearch'
|
|
import { AlertError } from '@/components/ui/AlertError'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { FilterPopover } from '@/components/ui/FilterPopover'
|
|
import { FormHeader } from '@/components/ui/Forms/FormHeader'
|
|
import { InlineLink } from '@/components/ui/InlineLink'
|
|
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
|
|
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
|
|
import { useIndexWorkerStatusQuery } from '@/data/auth/index-worker-status-query'
|
|
import { authKeys } from '@/data/auth/keys'
|
|
import { useUserDeleteMutation } from '@/data/auth/user-delete-mutation'
|
|
import { useUserIndexStatusesQuery } from '@/data/auth/user-search-indexes-query'
|
|
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 { PROJECT_STATUS } from '@/lib/constants/infrastructure'
|
|
import { cleanPointerEventsNoneOnBody, isAtBottom } from '@/lib/helpers'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
|
|
|
|
const SORT_BY_VALUE_COUNT_THRESHOLD = 10_000
|
|
const IMPROVED_SEARCH_COUNT_THRESHOLD = 10_000
|
|
|
|
const INDEX_WORKER_LOGS_SEARCH_STRING = `select id, auth_logs.timestamp, metadata.level, event_message, metadata.msg as msg, metadata.error
|
|
from auth_logs
|
|
cross join unnest(metadata) as metadata
|
|
where metadata.worker_type = 'apiworker_index_worker'
|
|
and auth_logs.timestamp >= timestamp_sub(current_timestamp(), interval 3 hour)
|
|
order by timestamp desc
|
|
limit 100`
|
|
|
|
export const UsersV2 = () => {
|
|
const router = useRouter()
|
|
const queryClient = useQueryClient()
|
|
const { ref: projectRef } = useParams()
|
|
const {
|
|
data: project,
|
|
isPending: isPendingProject,
|
|
isError: isProjectError,
|
|
} = useSelectedProjectQuery()
|
|
const roleImpersonationState = useRoleImpersonationStateSnapshot()
|
|
|
|
const gridRef = useRef<DataGridHandle>(null)
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
const xScroll = useRef<number>(0)
|
|
const track = useTrack()
|
|
|
|
const {
|
|
authenticationShowProviderFilter: showProviderFilter,
|
|
authenticationShowSortByEmail: showSortByEmail,
|
|
authenticationShowSortByPhone: showSortByPhone,
|
|
authenticationShowUserTypeFilter: showUserTypeFilter,
|
|
authenticationShowEmailPhoneColumns: showEmailPhoneColumns,
|
|
} = useIsFeatureEnabled([
|
|
'authentication:show_provider_filter',
|
|
'authentication:show_sort_by_email',
|
|
'authentication:show_sort_by_phone',
|
|
'authentication:show_user_type_filter',
|
|
'authentication:show_email_phone_columns',
|
|
])
|
|
|
|
const userTableColumns = useMemo(() => {
|
|
if (showEmailPhoneColumns) return USERS_TABLE_COLUMNS
|
|
else {
|
|
return USERS_TABLE_COLUMNS.filter((col) => {
|
|
if (col.id === 'email' || col.id === 'phone') return false
|
|
return true
|
|
})
|
|
}
|
|
}, [showEmailPhoneColumns])
|
|
|
|
const [specificFilterColumn, setSpecificFilterColumn] = useQueryState<SpecificFilterColumn>(
|
|
'filter',
|
|
parseAsStringEnum<SpecificFilterColumn>([
|
|
'id',
|
|
'email',
|
|
'phone',
|
|
'name',
|
|
'freeform',
|
|
]).withDefault('email')
|
|
)
|
|
const [filterUserType, setFilterUserType] = useQueryState(
|
|
'userType',
|
|
parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all')
|
|
)
|
|
const [filterKeywords] = useQueryState('keywords', { defaultValue: '' })
|
|
const [sortByValue, setSortByValue] = useQueryState('sortBy', { defaultValue: 'created_at:desc' })
|
|
const [sortColumn, sortOrder] = sortByValue.split(':')
|
|
const [selectedColumns, setSelectedColumns] = useQueryState(
|
|
'columns',
|
|
parseAsArrayOf(parseAsString, ',').withDefault([])
|
|
)
|
|
const [selectedProviders, setSelectedProviders] = useQueryState(
|
|
'providers',
|
|
parseAsArrayOf(parseAsString, ',').withDefault([])
|
|
)
|
|
const [selectedId, setSelectedId] = useQueryState(
|
|
'show',
|
|
parseAsString.withOptions({ history: 'push', clearOnDefault: true })
|
|
)
|
|
|
|
const [improvedSearchDismissed, setImprovedSearchDismissed] = useLocalStorageQuery(
|
|
LOCAL_STORAGE_KEYS.AUTH_USERS_IMPROVED_SEARCH_DISMISSED(projectRef ?? ''),
|
|
false
|
|
)
|
|
|
|
// [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<SpecificFilterColumn>(
|
|
LOCAL_STORAGE_KEYS.AUTH_USERS_FILTER(projectRef ?? ''),
|
|
'email'
|
|
)
|
|
|
|
const [
|
|
localStorageSortByValue,
|
|
setLocalStorageSortByValue,
|
|
{ isSuccess: isLocalStorageSortByValueLoaded },
|
|
] = useLocalStorageQuery<string>(
|
|
LOCAL_STORAGE_KEYS.AUTH_USERS_SORT_BY_VALUE(projectRef ?? ''),
|
|
'id'
|
|
)
|
|
|
|
const [
|
|
columnConfiguration,
|
|
setColumnConfiguration,
|
|
{ isSuccess: isSuccessStorage, isError: isErrorStorage, error: errorStorage },
|
|
] = useLocalStorageQuery(
|
|
LOCAL_STORAGE_KEYS.AUTH_USERS_COLUMNS_CONFIGURATION(projectRef ?? ''),
|
|
null as ColumnConfiguration[] | null
|
|
)
|
|
|
|
const [columns, setColumns] = useState<Column<any>[]>([])
|
|
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
|
|
const [selectedUserToDelete, setSelectedUserToDelete] = useState<User>()
|
|
const [isDeletingUsers, setIsDeletingUsers] = useState(false)
|
|
const [showFreeformWarning, setShowFreeformWarning] = useState(false)
|
|
const [showCreateIndexesModal, setShowCreateIndexesModal] = useState(false)
|
|
const [search, setSearch] = useState(filterKeywords)
|
|
|
|
const { data: totalUsersCountData, isSuccess: isCountLoaded } = useUsersCountQuery(
|
|
{
|
|
projectRef,
|
|
connectionString: project?.connectionString,
|
|
// [Joshen] Do not change the following, these are to match the count query in UsersFooter
|
|
// on initial load with no search configuration so that we only fire 1 count request at the
|
|
// beginning. The count value is for all users - should disregard any search configuration
|
|
keywords: '',
|
|
filter: undefined,
|
|
providers: [],
|
|
forceExactCount: false,
|
|
},
|
|
{ placeholderData: keepPreviousData }
|
|
)
|
|
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 === 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
|
|
* 4. They have not dismissed the alert
|
|
*/
|
|
const isCountWithinThresholdForOptIn =
|
|
isCountLoaded && totalUsers <= IMPROVED_SEARCH_COUNT_THRESHOLD
|
|
const showImprovedSearchOptIn =
|
|
isImprovedUserSearchFlagEnabled &&
|
|
authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === false &&
|
|
isCountWithinThresholdForOptIn &&
|
|
!improvedSearchDismissed
|
|
|
|
/**
|
|
* 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,
|
|
isSuccess,
|
|
isPending,
|
|
isLoading,
|
|
isRefetching,
|
|
isError,
|
|
isFetchingNextPage,
|
|
refetch,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
} = useUsersInfiniteQuery(
|
|
{
|
|
projectRef,
|
|
connectionString: project?.connectionString,
|
|
keywords: filterKeywords,
|
|
filter:
|
|
(specificFilterColumn !== 'freeform' && !improvedSearchEnabled) || filterUserType === 'all'
|
|
? undefined
|
|
: filterUserType,
|
|
providers: selectedProviders,
|
|
sort: sortColumn as 'id' | 'created_at' | 'email' | 'phone',
|
|
order: sortOrder as 'asc' | 'desc',
|
|
// 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,
|
|
}
|
|
)
|
|
|
|
const { mutateAsync: deleteUser } = useUserDeleteMutation()
|
|
|
|
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
|
|
const selectedUser = users?.find((u) => u.id === selectedId)?.id
|
|
|
|
// [Joshen] Only relevant for when selecting one user only
|
|
const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0])
|
|
|
|
const telemetryProps = {
|
|
sort_column: sortColumn,
|
|
sort_order: sortOrder,
|
|
providers: selectedProviders,
|
|
user_type: filterUserType === 'all' ? undefined : filterUserType,
|
|
keywords: filterKeywords,
|
|
filter_column: specificFilterColumn === 'freeform' ? undefined : specificFilterColumn,
|
|
}
|
|
|
|
const updateStorageFilter = (value: SpecificFilterColumn) => {
|
|
setLocalStorageFilter(value)
|
|
setSpecificFilterColumn(value)
|
|
if (value !== 'freeform' && !improvedSearchEnabled) {
|
|
updateSortByValue('id:asc')
|
|
}
|
|
}
|
|
|
|
const updateSortByValue = (value: string) => {
|
|
if (isCountWithinThresholdForSortBy) setLocalStorageSortByValue(value)
|
|
setSortByValue(value)
|
|
}
|
|
|
|
const onSelectImpersonateUser = async (user: User, destination: 'sql' | 'table-editor') => {
|
|
await roleImpersonationState.setRole({
|
|
type: 'postgrest',
|
|
role: 'authenticated',
|
|
userType: 'native',
|
|
user,
|
|
aal: 'aal1',
|
|
})
|
|
|
|
if (destination === 'sql') {
|
|
router.push(`/project/${projectRef}/sql`)
|
|
} else {
|
|
router.push(`/project/${projectRef}/editor`)
|
|
}
|
|
}
|
|
|
|
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 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({ queryKey: authKeys.usersInfinite(projectRef) }),
|
|
])
|
|
toast.success(
|
|
`Successfully deleted the selected ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`
|
|
)
|
|
setShowDeleteModal(false)
|
|
setSelectedUsers(new Set([]))
|
|
|
|
if (userIds.includes(selectedUser)) setSelectedId(null)
|
|
} catch (error: any) {
|
|
toast.error(`Failed to delete selected users: ${error.message}`)
|
|
} finally {
|
|
setIsDeletingUsers(false)
|
|
}
|
|
}
|
|
|
|
const handleRefresh = () => {
|
|
refetch()
|
|
track('auth_users_search_submitted', {
|
|
trigger: 'refresh_button',
|
|
...telemetryProps,
|
|
})
|
|
}
|
|
|
|
const { onCellKeyDown, showDeleteModal, setShowDeleteModal } = useAuthUsersShortcuts({
|
|
gridRef,
|
|
searchInputRef,
|
|
users,
|
|
selectedUsers,
|
|
setSelectedUsers,
|
|
setSearch,
|
|
onRefresh: handleRefresh,
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isRefetching &&
|
|
(isSuccessStorage ||
|
|
(isErrorStorage && (errorStorage as Error).message.includes('data is undefined')))
|
|
) {
|
|
const columns = formatUserColumns({
|
|
specificFilterColumn,
|
|
columns: userTableColumns,
|
|
config: columnConfiguration ?? [],
|
|
users: users ?? [],
|
|
visibleColumns: selectedColumns,
|
|
setSortByValue: updateSortByValue,
|
|
onSelectDeleteUser: setSelectedUserToDelete,
|
|
onSelectImpersonateUser,
|
|
})
|
|
setColumns(columns)
|
|
if (columns.length < userTableColumns.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,
|
|
specificFilterColumn,
|
|
])
|
|
|
|
// [Joshen] Load URL state for filter column and sort by only once, if no respective values found in URL params
|
|
useEffect(() => {
|
|
if (
|
|
isLocalStorageFilterLoaded &&
|
|
isLocalStorageSortByValueLoaded &&
|
|
isCountLoaded &&
|
|
isCountWithinThresholdForSortBy
|
|
) {
|
|
if (specificFilterColumn === 'email' && localStorageFilter !== 'email') {
|
|
setSpecificFilterColumn(localStorageFilter)
|
|
}
|
|
if (sortByValue === 'id:asc' && localStorageSortByValue !== 'id:asc') {
|
|
setSortByValue(localStorageSortByValue)
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isLocalStorageFilterLoaded, isLocalStorageSortByValueLoaded, isCountLoaded])
|
|
|
|
return (
|
|
<>
|
|
<div className="h-full flex flex-col">
|
|
<FormHeader className="py-4 px-6 mb-0! border-b" title="Users" />
|
|
|
|
{showImprovedSearchOptIn && (
|
|
<Alert className="rounded-none mb-0 border-0 relative">
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
onClick={() => setImprovedSearchDismissed(true)}
|
|
className="absolute top-3 right-3 opacity-30 hover:opacity-100 transition-opacity"
|
|
>
|
|
<X size={14} className="text-foreground-light" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Dismiss</TooltipContent>
|
|
</Tooltip>
|
|
<InfoIcon className="size-4" />
|
|
<AlertTitle>Upgrade to an improved search experience</AlertTitle>
|
|
<AlertDescription className="flex justify-between items-center">
|
|
<div>
|
|
Enable faster and more reliable searching, sorting, and filtering of your users.
|
|
</div>
|
|
<Button
|
|
icon={<WandSparklesIcon />}
|
|
onClick={() => setShowCreateIndexesModal(true)}
|
|
loading={isUpdatingAuthConfig}
|
|
type="default"
|
|
>
|
|
Upgrade search
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{indexWorkerInProgress && (
|
|
<Alert className="rounded-none mb-0 border-0 border-t">
|
|
<InfoIcon className="size-4" />
|
|
<AlertTitle>Index creation is in progress</AlertTitle>
|
|
<AlertDescription className="flex justify-between items-center">
|
|
<div>
|
|
The indexes are currently being created. This process may take some time depending
|
|
on the number of users in your project.
|
|
</div>
|
|
|
|
<Button type="link" iconRight={<ExternalLinkIcon />} asChild>
|
|
<Link
|
|
href={`/project/${projectRef}/logs/explorer?q=${encodeURI(INDEX_WORKER_LOGS_SEARCH_STRING)}`}
|
|
target="_blank"
|
|
>
|
|
View logs
|
|
</Link>
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="bg-surface-200 py-3 px-4 md:px-6 flex flex-col lg:flex-row lg:items-start justify-between gap-2">
|
|
{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">
|
|
<UsersSearch
|
|
ref={searchInputRef}
|
|
search={search}
|
|
setSearch={setSearch}
|
|
improvedSearchEnabled={improvedSearchEnabled}
|
|
telemetryProps={telemetryProps}
|
|
onSelectFilterColumn={(value) => {
|
|
if (value === 'freeform') {
|
|
if (isCountWithinThresholdForSortBy) {
|
|
updateStorageFilter(value)
|
|
} else {
|
|
setShowFreeformWarning(true)
|
|
}
|
|
} else {
|
|
updateStorageFilter(value)
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{showUserTypeFilter &&
|
|
(specificFilterColumn === 'freeform' || improvedSearchEnabled) && (
|
|
<Select
|
|
value={filterUserType}
|
|
onValueChange={(val) => {
|
|
setFilterUserType(val as Filter)
|
|
track('auth_users_search_submitted', {
|
|
trigger: 'user_type_filter',
|
|
...telemetryProps,
|
|
user_type: val,
|
|
})
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
size="tiny"
|
|
className={cn(
|
|
'w-[140px] bg-transparent!',
|
|
filterUserType === 'all' && 'border-dashed'
|
|
)}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="all" className="text-xs">
|
|
All users
|
|
</SelectItem>
|
|
<SelectItem value="verified" className="text-xs">
|
|
Verified users
|
|
</SelectItem>
|
|
<SelectItem value="unverified" className="text-xs">
|
|
Unverified users
|
|
</SelectItem>
|
|
<SelectItem value="anonymous" className="text-xs">
|
|
Anonymous users
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{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)
|
|
track('auth_users_search_submitted', {
|
|
trigger: 'provider_filter',
|
|
...telemetryProps,
|
|
providers,
|
|
})
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<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={userTableColumns.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 = userTableColumns.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: userTableColumns.find((c) => c.id === col)?.width,
|
|
})
|
|
})
|
|
}
|
|
|
|
const updatedColumns = formatUserColumns({
|
|
specificFilterColumn,
|
|
columns: userTableColumns,
|
|
config: updatedConfig,
|
|
users: users ?? [],
|
|
visibleColumns: value,
|
|
setSortByValue: updateSortByValue,
|
|
onSelectDeleteUser: setSelectedUserToDelete,
|
|
onSelectImpersonateUser,
|
|
})
|
|
|
|
setSelectedColumns(value)
|
|
setColumns(updatedColumns)
|
|
saveColumnConfiguration('toggle', { columns: updatedColumns })
|
|
}}
|
|
/>
|
|
|
|
<SortDropdown
|
|
specificFilterColumn={specificFilterColumn}
|
|
sortColumn={sortColumn}
|
|
sortOrder={sortOrder}
|
|
sortByValue={sortByValue}
|
|
setSortByValue={(value) => {
|
|
const [sortColumn, sortOrder] = value.split(':')
|
|
updateSortByValue(value)
|
|
track('auth_users_search_submitted', {
|
|
trigger: 'sort_change',
|
|
...telemetryProps,
|
|
sort_column: sortColumn,
|
|
sort_order: sortOrder,
|
|
})
|
|
}}
|
|
showSortByEmail={showSortByEmail}
|
|
showSortByPhone={showSortByPhone}
|
|
improvedSearchEnabled={improvedSearchEnabled}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-x-2">
|
|
<ButtonTooltip
|
|
size="tiny"
|
|
icon={<RefreshCw />}
|
|
type="default"
|
|
className="w-7"
|
|
loading={isRefetching && !isFetchingNextPage}
|
|
onClick={handleRefresh}
|
|
tooltip={{ content: { side: 'bottom', text: 'Refresh' } }}
|
|
aria-label="Refresh"
|
|
/>
|
|
<AddUserDropdown />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
|
|
<ResizablePanelGroup
|
|
orientation="horizontal"
|
|
className="relative flex grow bg-alternative min-h-0"
|
|
autoSaveId="query-performance-layout-v1"
|
|
>
|
|
<ResizablePanel>
|
|
<div className="flex flex-col w-full h-full">
|
|
<DataGrid
|
|
ref={gridRef}
|
|
className="grow border-t-0! border-b-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-hidden [&>.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)
|
|
}}
|
|
onCellKeyDown={onCellKeyDown}
|
|
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) {
|
|
setSelectedId(props.row.id)
|
|
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
},
|
|
noRowsFallback: isPendingProject ? (
|
|
<div className="absolute top-14 px-6 w-full">
|
|
<GenericSkeletonLoader />
|
|
</div>
|
|
) : project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY || isProjectError ? (
|
|
<div className="absolute top-14 px-6 flex flex-col items-center justify-center w-full">
|
|
<AlertError
|
|
subject="Unable to load users"
|
|
error={{
|
|
message:
|
|
'Could not connect to the database. Please check your project status.',
|
|
}}
|
|
/>
|
|
</div>
|
|
) : isPending ? (
|
|
<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>
|
|
) : isSuccess ? (
|
|
<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">
|
|
{filterUserType !== 'all' || filterKeywords.length > 0
|
|
? 'No users found'
|
|
: 'No users in your project'}
|
|
</p>
|
|
<p className="text-foreground-light">
|
|
{filterUserType !== '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>
|
|
) : null,
|
|
}}
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
{!!selectedId && <UserPanel />}
|
|
</ResizablePanelGroup>
|
|
|
|
<UsersFooter
|
|
filter={filterUserType}
|
|
filterKeywords={filterKeywords}
|
|
selectedProviders={selectedProviders}
|
|
specificFilterColumn={specificFilterColumn}
|
|
/>
|
|
</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>
|
|
|
|
<ConfirmationModal
|
|
size="medium"
|
|
variant="warning"
|
|
visible={showFreeformWarning}
|
|
confirmLabel="Confirm"
|
|
title="Confirm to search across all columns"
|
|
onConfirm={() => {
|
|
updateStorageFilter('freeform')
|
|
setShowFreeformWarning(false)
|
|
}}
|
|
onCancel={() => setShowFreeformWarning(false)}
|
|
alert={{
|
|
base: { variant: 'warning' },
|
|
title: 'Searching across all columns is not recommended with many users',
|
|
description:
|
|
'This may adversely impact your database, in particular if your project has a large number of users - use with caution. Search mode will not be persisted across browser sessions as a safeguard.',
|
|
}}
|
|
>
|
|
<p className="text-foreground-light text-sm">
|
|
This will allow you to search across user ID, email, phone number, and display name
|
|
through a single input field. You will also be able to filter users by provider and sort
|
|
on users across different columns.
|
|
</p>
|
|
</ConfirmationModal>
|
|
|
|
<ConfirmationModal
|
|
size="medium"
|
|
visible={showCreateIndexesModal}
|
|
confirmLabel="Upgrade search"
|
|
title="Upgrade to improved search"
|
|
onConfirm={() => {
|
|
handleEnableUserSearchIndexes()
|
|
setShowCreateIndexesModal(false)
|
|
}}
|
|
onCancel={() => setShowCreateIndexesModal(false)}
|
|
alert={{
|
|
title: 'Improved search experience',
|
|
description:
|
|
'This will create indexes to enable faster and more reliable searching, sorting, and filtering of your users.',
|
|
}}
|
|
>
|
|
<ul className="text-sm list-disc pl-4 my-3 flex flex-col gap-2">
|
|
<li className="marker:text-foreground-light">
|
|
Creating these indexes may temporarily impact database performance.
|
|
</li>
|
|
<li className="marker:text-foreground-light">
|
|
Depending on the number of users, this may take some time to complete.
|
|
</li>
|
|
<li className="marker:text-foreground-light">
|
|
You can continue using the Auth Users page while the indexes are being created, but
|
|
improvements will only take effect once complete.
|
|
</li>
|
|
<li className="marker:text-foreground-light">
|
|
You can monitor the progress in the{' '}
|
|
<InlineLink
|
|
href={`/project/${projectRef}/logs/explorer?q=${encodeURI(INDEX_WORKER_LOGS_SEARCH_STRING)}`}
|
|
target="_blank"
|
|
>
|
|
project logs
|
|
</InlineLink>
|
|
. If you encounter any issues, please contact Supabase support for assistance.
|
|
</li>
|
|
</ul>
|
|
</ConfirmationModal>
|
|
|
|
{/* [Joshen] For deleting via context menu, the dialog above is dependent on the selectedUsers state */}
|
|
<DeleteUserModal
|
|
visible={!!selectedUserToDelete}
|
|
selectedUser={selectedUserToDelete}
|
|
onClose={() => {
|
|
setSelectedUserToDelete(undefined)
|
|
cleanPointerEventsNoneOnBody()
|
|
}}
|
|
onDeleteSuccess={() => {
|
|
if (selectedUserToDelete?.id === selectedUser) setSelectedId(null)
|
|
setSelectedUserToDelete(undefined)
|
|
cleanPointerEventsNoneOnBody(500)
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|