| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010 |
- // @ts-nocheck
- 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 { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { PROJECT_STATUS } from '@/lib/constants/infrastructure'
- import { cleanPointerEventsNoneOnBody, isAtBottom } from '@/lib/helpers'
- 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 { data: selectedOrg } = useSelectedOrganizationQuery()
- const roleImpersonationState = useRoleImpersonationStateSnapshot()
- const gridRef = useRef<DataGridHandle>(null)
- const searchInputRef = useRef<HTMLInputElement>(null)
- const xScroll = useRef<number>(0)
- const { mutate: sendEvent } = useSendEventMutation()
- 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 telemetryGroups = {
- project: projectRef ?? 'Unknown',
- organization: selectedOrg?.slug ?? 'Unknown',
- }
- 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()
- sendEvent({
- action: 'auth_users_search_submitted',
- properties: {
- trigger: 'refresh_button',
- ...telemetryProps,
- },
- groups: telemetryGroups,
- })
- }
- 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}
- telemetryGroups={telemetryGroups}
- 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)
- sendEvent({
- action: 'auth_users_search_submitted',
- properties: {
- trigger: 'user_type_filter',
- ...telemetryProps,
- user_type: val,
- },
- groups: telemetryGroups,
- })
- }}
- >
- <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)
- sendEvent({
- action: 'auth_users_search_submitted',
- properties: {
- trigger: 'provider_filter',
- ...telemetryProps,
- providers,
- },
- groups: telemetryGroups,
- })
- }}
- />
- )}
- <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)
- sendEvent({
- action: 'auth_users_search_submitted',
- properties: {
- trigger: 'sort_change',
- ...telemetryProps,
- sort_column: sortColumn,
- sort_order: sortOrder,
- },
- groups: telemetryGroups,
- })
- }}
- 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"
- 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 Briven 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)
- }}
- />
- </>
- )
- }
|