UsersV2.tsx1010 lines · main
1// @ts-nocheck
2import type { OptimizedSearchColumns } from '@supabase/pg-meta'
3import { USER_SEARCH_INDEXES } from '@supabase/pg-meta'
4import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
5import AwesomeDebouncePromise from 'awesome-debounce-promise'
6import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
7import {
8 ExternalLinkIcon,
9 InfoIcon,
10 RefreshCw,
11 Trash,
12 Users,
13 WandSparklesIcon,
14 X,
15} from 'lucide-react'
16import Link from 'next/link'
17import { useRouter } from 'next/router'
18import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'
19import { UIEvent, useEffect, useMemo, useRef, useState } from 'react'
20import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
21import { toast } from 'sonner'
22import {
23 Alert,
24 AlertDescription,
25 AlertTitle,
26 Button,
27 cn,
28 LoadingLine,
29 ResizablePanel,
30 ResizablePanelGroup,
31 Select,
32 SelectContent,
33 SelectGroup,
34 SelectItem,
35 SelectTrigger,
36 SelectValue,
37 Tooltip,
38 TooltipContent,
39 TooltipTrigger,
40} from 'ui'
41import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
42import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
43
44import { AddUserDropdown } from './AddUserDropdown'
45import { DeleteUserModal } from './DeleteUserModal'
46import { SortDropdown } from './SortDropdown'
47import { useAuthUsersShortcuts } from './useAuthUsersShortcuts'
48import { UserPanel } from './UserPanel'
49import type { SpecificFilterColumn } from './Users.constants'
50import {
51 ColumnConfiguration,
52 Filter,
53 MAX_BULK_DELETE,
54 PROVIDER_FILTER_OPTIONS,
55 USERS_TABLE_COLUMNS,
56} from './Users.constants'
57import { formatUserColumns, formatUsersData } from './Users.utils'
58import { UsersFooter } from './UsersFooter'
59import { UsersSearch } from './UsersSearch'
60import { AlertError } from '@/components/ui/AlertError'
61import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
62import { FilterPopover } from '@/components/ui/FilterPopover'
63import { FormHeader } from '@/components/ui/Forms/FormHeader'
64import { InlineLink } from '@/components/ui/InlineLink'
65import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
66import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
67import { useIndexWorkerStatusQuery } from '@/data/auth/index-worker-status-query'
68import { authKeys } from '@/data/auth/keys'
69import { useUserDeleteMutation } from '@/data/auth/user-delete-mutation'
70import { useUserIndexStatusesQuery } from '@/data/auth/user-search-indexes-query'
71import { useUsersCountQuery } from '@/data/auth/users-count-query'
72import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query'
73import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
74import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
75import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
76import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
77import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
78import { PROJECT_STATUS } from '@/lib/constants/infrastructure'
79import { cleanPointerEventsNoneOnBody, isAtBottom } from '@/lib/helpers'
80import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
81
82const SORT_BY_VALUE_COUNT_THRESHOLD = 10_000
83const IMPROVED_SEARCH_COUNT_THRESHOLD = 10_000
84
85const INDEX_WORKER_LOGS_SEARCH_STRING = `select id, auth_logs.timestamp, metadata.level, event_message, metadata.msg as msg, metadata.error
86from auth_logs
87cross join unnest(metadata) as metadata
88where metadata.worker_type = 'apiworker_index_worker'
89 and auth_logs.timestamp >= timestamp_sub(current_timestamp(), interval 3 hour)
90order by timestamp desc
91limit 100`
92
93export const UsersV2 = () => {
94 const router = useRouter()
95 const queryClient = useQueryClient()
96 const { ref: projectRef } = useParams()
97 const {
98 data: project,
99 isPending: isPendingProject,
100 isError: isProjectError,
101 } = useSelectedProjectQuery()
102 const { data: selectedOrg } = useSelectedOrganizationQuery()
103 const roleImpersonationState = useRoleImpersonationStateSnapshot()
104
105 const gridRef = useRef<DataGridHandle>(null)
106 const searchInputRef = useRef<HTMLInputElement>(null)
107 const xScroll = useRef<number>(0)
108 const { mutate: sendEvent } = useSendEventMutation()
109
110 const {
111 authenticationShowProviderFilter: showProviderFilter,
112 authenticationShowSortByEmail: showSortByEmail,
113 authenticationShowSortByPhone: showSortByPhone,
114 authenticationShowUserTypeFilter: showUserTypeFilter,
115 authenticationShowEmailPhoneColumns: showEmailPhoneColumns,
116 } = useIsFeatureEnabled([
117 'authentication:show_provider_filter',
118 'authentication:show_sort_by_email',
119 'authentication:show_sort_by_phone',
120 'authentication:show_user_type_filter',
121 'authentication:show_email_phone_columns',
122 ])
123
124 const userTableColumns = useMemo(() => {
125 if (showEmailPhoneColumns) return USERS_TABLE_COLUMNS
126 else {
127 return USERS_TABLE_COLUMNS.filter((col) => {
128 if (col.id === 'email' || col.id === 'phone') return false
129 return true
130 })
131 }
132 }, [showEmailPhoneColumns])
133
134 const [specificFilterColumn, setSpecificFilterColumn] = useQueryState<SpecificFilterColumn>(
135 'filter',
136 parseAsStringEnum<SpecificFilterColumn>([
137 'id',
138 'email',
139 'phone',
140 'name',
141 'freeform',
142 ]).withDefault('email')
143 )
144 const [filterUserType, setFilterUserType] = useQueryState(
145 'userType',
146 parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all')
147 )
148 const [filterKeywords] = useQueryState('keywords', { defaultValue: '' })
149 const [sortByValue, setSortByValue] = useQueryState('sortBy', { defaultValue: 'created_at:desc' })
150 const [sortColumn, sortOrder] = sortByValue.split(':')
151 const [selectedColumns, setSelectedColumns] = useQueryState(
152 'columns',
153 parseAsArrayOf(parseAsString, ',').withDefault([])
154 )
155 const [selectedProviders, setSelectedProviders] = useQueryState(
156 'providers',
157 parseAsArrayOf(parseAsString, ',').withDefault([])
158 )
159 const [selectedId, setSelectedId] = useQueryState(
160 'show',
161 parseAsString.withOptions({ history: 'push', clearOnDefault: true })
162 )
163
164 const [improvedSearchDismissed, setImprovedSearchDismissed] = useLocalStorageQuery(
165 LOCAL_STORAGE_KEYS.AUTH_USERS_IMPROVED_SEARCH_DISMISSED(projectRef ?? ''),
166 false
167 )
168
169 // [Joshen] Opting to store filter column, into local storage for now, which will initialize
170 // the page when landing on auth users page only if no query params for filter column provided
171 const [localStorageFilter, setLocalStorageFilter, { isSuccess: isLocalStorageFilterLoaded }] =
172 useLocalStorageQuery<SpecificFilterColumn>(
173 LOCAL_STORAGE_KEYS.AUTH_USERS_FILTER(projectRef ?? ''),
174 'email'
175 )
176
177 const [
178 localStorageSortByValue,
179 setLocalStorageSortByValue,
180 { isSuccess: isLocalStorageSortByValueLoaded },
181 ] = useLocalStorageQuery<string>(
182 LOCAL_STORAGE_KEYS.AUTH_USERS_SORT_BY_VALUE(projectRef ?? ''),
183 'id'
184 )
185
186 const [
187 columnConfiguration,
188 setColumnConfiguration,
189 { isSuccess: isSuccessStorage, isError: isErrorStorage, error: errorStorage },
190 ] = useLocalStorageQuery(
191 LOCAL_STORAGE_KEYS.AUTH_USERS_COLUMNS_CONFIGURATION(projectRef ?? ''),
192 null as ColumnConfiguration[] | null
193 )
194
195 const [columns, setColumns] = useState<Column<any>[]>([])
196 const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
197 const [selectedUserToDelete, setSelectedUserToDelete] = useState<User>()
198 const [isDeletingUsers, setIsDeletingUsers] = useState(false)
199 const [showFreeformWarning, setShowFreeformWarning] = useState(false)
200 const [showCreateIndexesModal, setShowCreateIndexesModal] = useState(false)
201 const [search, setSearch] = useState(filterKeywords)
202
203 const { data: totalUsersCountData, isSuccess: isCountLoaded } = useUsersCountQuery(
204 {
205 projectRef,
206 connectionString: project?.connectionString,
207 // [Joshen] Do not change the following, these are to match the count query in UsersFooter
208 // on initial load with no search configuration so that we only fire 1 count request at the
209 // beginning. The count value is for all users - should disregard any search configuration
210 keywords: '',
211 filter: undefined,
212 providers: [],
213 forceExactCount: false,
214 },
215 { placeholderData: keepPreviousData }
216 )
217 const totalUsers = totalUsersCountData?.count ?? 0
218 const isCountWithinThresholdForSortBy = totalUsers <= SORT_BY_VALUE_COUNT_THRESHOLD
219
220 const isImprovedUserSearchFlagEnabled = useFlag('improvedUserSearch')
221 const { data: authConfig, isLoading: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
222 const {
223 data: userSearchIndexes,
224 isError: isUserSearchIndexesError,
225 isLoading: isUserSearchIndexesLoading,
226 } = useUserIndexStatusesQuery({ projectRef, connectionString: project?.connectionString })
227 const { data: indexWorkerStatus, isLoading: isIndexWorkerStatusLoading } =
228 useIndexWorkerStatusQuery({
229 projectRef,
230 connectionString: project?.connectionString,
231 })
232 const { mutate: updateAuthConfig, isPending: isUpdatingAuthConfig } = useAuthConfigUpdateMutation(
233 {
234 onSuccess: () => {
235 toast.success('Initiated creation of user search indexes')
236 },
237 onError: (error) => {
238 toast.error(`Failed to initiate creation of user search indexes: ${error?.message}`)
239 },
240 }
241 )
242
243 const handleEnableUserSearchIndexes = () => {
244 if (!projectRef) return console.error('Project ref is required')
245 updateAuthConfig({
246 projectRef: projectRef,
247 config: { INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST: true },
248 })
249 }
250
251 const userSearchIndexesAreValidAndReady =
252 !isUserSearchIndexesError &&
253 !isUserSearchIndexesLoading &&
254 userSearchIndexes?.length === USER_SEARCH_INDEXES.length &&
255 userSearchIndexes?.every((index) => index.is_valid && index.is_ready)
256
257 /**
258 * We want to show the improved search when:
259 * 1. The feature flag is enabled for them
260 * 2. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true)
261 * 3. The required indexes are valid and ready
262 */
263 const improvedSearchEnabled =
264 isImprovedUserSearchFlagEnabled &&
265 authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true &&
266 userSearchIndexesAreValidAndReady
267
268 /**
269 * We want to show users the improved search opt-in only if:
270 * 1. The feature flag is enabled for them
271 * 2. They have not opted in yet (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is false)
272 * 3. They have < threshold number of users
273 * 4. They have not dismissed the alert
274 */
275 const isCountWithinThresholdForOptIn =
276 isCountLoaded && totalUsers <= IMPROVED_SEARCH_COUNT_THRESHOLD
277 const showImprovedSearchOptIn =
278 isImprovedUserSearchFlagEnabled &&
279 authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === false &&
280 isCountWithinThresholdForOptIn &&
281 !improvedSearchDismissed
282
283 /**
284 * We want to show an "in progress" state when:
285 * 1. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true)
286 * 2. The index worker is currently in progress
287 */
288 const indexWorkerInProgress =
289 authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true &&
290 indexWorkerStatus?.is_in_progress === true
291
292 const {
293 data,
294 error,
295 isSuccess,
296 isPending,
297 isLoading,
298 isRefetching,
299 isError,
300 isFetchingNextPage,
301 refetch,
302 hasNextPage,
303 fetchNextPage,
304 } = useUsersInfiniteQuery(
305 {
306 projectRef,
307 connectionString: project?.connectionString,
308 keywords: filterKeywords,
309 filter:
310 (specificFilterColumn !== 'freeform' && !improvedSearchEnabled) || filterUserType === 'all'
311 ? undefined
312 : filterUserType,
313 providers: selectedProviders,
314 sort: sortColumn as 'id' | 'created_at' | 'email' | 'phone',
315 order: sortOrder as 'asc' | 'desc',
316 // improved search will always have a column specified
317 ...(specificFilterColumn !== 'freeform' || improvedSearchEnabled
318 ? { column: specificFilterColumn as OptimizedSearchColumns }
319 : { column: undefined }),
320
321 improvedSearchEnabled: improvedSearchEnabled,
322 },
323 {
324 placeholderData: Boolean(filterKeywords) ? keepPreviousData : undefined,
325 // [Joshen] This is to prevent the dashboard from invalidating when refocusing as it may create
326 // a barrage of requests to invalidate each page esp when the project has many many users.
327 staleTime: Infinity,
328 // NOTE(iat): query the user data only after we know whether to show improved search or not
329 enabled: !isUserSearchIndexesLoading && !isAuthConfigLoading && !isIndexWorkerStatusLoading,
330 }
331 )
332
333 const { mutateAsync: deleteUser } = useUserDeleteMutation()
334
335 const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
336 const selectedUser = users?.find((u) => u.id === selectedId)?.id
337
338 // [Joshen] Only relevant for when selecting one user only
339 const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0])
340
341 const telemetryProps = {
342 sort_column: sortColumn,
343 sort_order: sortOrder,
344 providers: selectedProviders,
345 user_type: filterUserType === 'all' ? undefined : filterUserType,
346 keywords: filterKeywords,
347 filter_column: specificFilterColumn === 'freeform' ? undefined : specificFilterColumn,
348 }
349 const telemetryGroups = {
350 project: projectRef ?? 'Unknown',
351 organization: selectedOrg?.slug ?? 'Unknown',
352 }
353
354 const updateStorageFilter = (value: SpecificFilterColumn) => {
355 setLocalStorageFilter(value)
356 setSpecificFilterColumn(value)
357 if (value !== 'freeform' && !improvedSearchEnabled) {
358 updateSortByValue('id:asc')
359 }
360 }
361
362 const updateSortByValue = (value: string) => {
363 if (isCountWithinThresholdForSortBy) setLocalStorageSortByValue(value)
364 setSortByValue(value)
365 }
366
367 const onSelectImpersonateUser = async (user: User, destination: 'sql' | 'table-editor') => {
368 await roleImpersonationState.setRole({
369 type: 'postgrest',
370 role: 'authenticated',
371 userType: 'native',
372 user,
373 aal: 'aal1',
374 })
375
376 if (destination === 'sql') {
377 router.push(`/project/${projectRef}/sql`)
378 } else {
379 router.push(`/project/${projectRef}/editor`)
380 }
381 }
382
383 const handleScroll = (event: UIEvent<HTMLDivElement>) => {
384 const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft
385 xScroll.current = event.currentTarget.scrollLeft
386
387 if (
388 isLoading ||
389 isFetchingNextPage ||
390 isScrollingHorizontally ||
391 !isAtBottom(event) ||
392 !hasNextPage
393 ) {
394 return
395 }
396 fetchNextPage()
397 }
398
399 const swapColumns = (data: any[], sourceIdx: number, targetIdx: number) => {
400 const updatedColumns = data.slice()
401 const [removed] = updatedColumns.splice(sourceIdx, 1)
402 updatedColumns.splice(targetIdx, 0, removed)
403 return updatedColumns
404 }
405
406 // [Joshen] Left off here - it's tricky trying to do both column toggling and re-ordering
407 const saveColumnConfiguration = AwesomeDebouncePromise(
408 (event: 'resize' | 'reorder' | 'toggle', value) => {
409 if (event === 'toggle') {
410 const columnConfig = value.columns.map((col: any) => ({
411 id: col.key,
412 width: col.width,
413 }))
414 setColumnConfiguration(columnConfig)
415 } else if (event === 'resize') {
416 const columnConfig = columns.map((col, idx) => ({
417 id: col.key,
418 width: idx === value.idx ? value.width : col.width,
419 }))
420 setColumnConfiguration(columnConfig)
421 } else if (event === 'reorder') {
422 const columnConfig = value.columns.map((col: any) => ({
423 id: col.key,
424 width: col.width,
425 }))
426 setColumnConfiguration(columnConfig)
427 }
428 },
429 500
430 )
431
432 const handleDeleteUsers = async () => {
433 if (!projectRef) return console.error('Project ref is required')
434 const userIds = [...selectedUsers]
435
436 setIsDeletingUsers(true)
437 try {
438 await Promise.all(
439 userIds.map((id) => deleteUser({ projectRef, userId: id, skipInvalidation: true }))
440 )
441 // [Joshen] Skip invalidation within RQ to prevent multiple requests, then invalidate once at the end
442 await Promise.all([
443 queryClient.invalidateQueries({ queryKey: authKeys.usersInfinite(projectRef) }),
444 ])
445 toast.success(
446 `Successfully deleted the selected ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`
447 )
448 setShowDeleteModal(false)
449 setSelectedUsers(new Set([]))
450
451 if (userIds.includes(selectedUser)) setSelectedId(null)
452 } catch (error: any) {
453 toast.error(`Failed to delete selected users: ${error.message}`)
454 } finally {
455 setIsDeletingUsers(false)
456 }
457 }
458
459 const handleRefresh = () => {
460 refetch()
461 sendEvent({
462 action: 'auth_users_search_submitted',
463 properties: {
464 trigger: 'refresh_button',
465 ...telemetryProps,
466 },
467 groups: telemetryGroups,
468 })
469 }
470
471 const { onCellKeyDown, showDeleteModal, setShowDeleteModal } = useAuthUsersShortcuts({
472 gridRef,
473 searchInputRef,
474 users,
475 selectedUsers,
476 setSelectedUsers,
477 setSearch,
478 onRefresh: handleRefresh,
479 })
480
481 useEffect(() => {
482 if (
483 !isRefetching &&
484 (isSuccessStorage ||
485 (isErrorStorage && (errorStorage as Error).message.includes('data is undefined')))
486 ) {
487 const columns = formatUserColumns({
488 specificFilterColumn,
489 columns: userTableColumns,
490 config: columnConfiguration ?? [],
491 users: users ?? [],
492 visibleColumns: selectedColumns,
493 setSortByValue: updateSortByValue,
494 onSelectDeleteUser: setSelectedUserToDelete,
495 onSelectImpersonateUser,
496 })
497 setColumns(columns)
498 if (columns.length < userTableColumns.length) {
499 setSelectedColumns(columns.filter((col) => col.key !== 'img').map((col) => col.key))
500 }
501 }
502 // eslint-disable-next-line react-hooks/exhaustive-deps
503 }, [
504 isSuccess,
505 isRefetching,
506 isSuccessStorage,
507 isErrorStorage,
508 errorStorage,
509 users,
510 selectedUsers,
511 specificFilterColumn,
512 ])
513
514 // [Joshen] Load URL state for filter column and sort by only once, if no respective values found in URL params
515 useEffect(() => {
516 if (
517 isLocalStorageFilterLoaded &&
518 isLocalStorageSortByValueLoaded &&
519 isCountLoaded &&
520 isCountWithinThresholdForSortBy
521 ) {
522 if (specificFilterColumn === 'email' && localStorageFilter !== 'email') {
523 setSpecificFilterColumn(localStorageFilter)
524 }
525 if (sortByValue === 'id:asc' && localStorageSortByValue !== 'id:asc') {
526 setSortByValue(localStorageSortByValue)
527 }
528 }
529 // eslint-disable-next-line react-hooks/exhaustive-deps
530 }, [isLocalStorageFilterLoaded, isLocalStorageSortByValueLoaded, isCountLoaded])
531
532 return (
533 <>
534 <div className="h-full flex flex-col">
535 <FormHeader className="py-4 px-6 mb-0! border-b" title="Users" />
536
537 {showImprovedSearchOptIn && (
538 <Alert className="rounded-none mb-0 border-0 relative">
539 <Tooltip>
540 <TooltipTrigger
541 onClick={() => setImprovedSearchDismissed(true)}
542 className="absolute top-3 right-3 opacity-30 hover:opacity-100 transition-opacity"
543 >
544 <X size={14} className="text-foreground-light" />
545 </TooltipTrigger>
546 <TooltipContent side="bottom">Dismiss</TooltipContent>
547 </Tooltip>
548 <InfoIcon className="size-4" />
549 <AlertTitle>Upgrade to an improved search experience</AlertTitle>
550 <AlertDescription className="flex justify-between items-center">
551 <div>
552 Enable faster and more reliable searching, sorting, and filtering of your users.
553 </div>
554 <Button
555 icon={<WandSparklesIcon />}
556 onClick={() => setShowCreateIndexesModal(true)}
557 loading={isUpdatingAuthConfig}
558 type="default"
559 >
560 Upgrade search
561 </Button>
562 </AlertDescription>
563 </Alert>
564 )}
565
566 {indexWorkerInProgress && (
567 <Alert className="rounded-none mb-0 border-0 border-t">
568 <InfoIcon className="size-4" />
569 <AlertTitle>Index creation is in progress</AlertTitle>
570 <AlertDescription className="flex justify-between items-center">
571 <div>
572 The indexes are currently being created. This process may take some time depending
573 on the number of users in your project.
574 </div>
575
576 <Button type="link" iconRight={<ExternalLinkIcon />} asChild>
577 <Link
578 href={`/project/${projectRef}/logs/explorer?q=${encodeURI(INDEX_WORKER_LOGS_SEARCH_STRING)}`}
579 target="_blank"
580 >
581 View logs
582 </Link>
583 </Button>
584 </AlertDescription>
585 </Alert>
586 )}
587
588 <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">
589 {selectedUsers.size > 0 ? (
590 <div className="flex items-center gap-x-2">
591 <Button type="default" icon={<Trash />} onClick={() => setShowDeleteModal(true)}>
592 Delete {selectedUsers.size} users
593 </Button>
594 <ButtonTooltip
595 type="default"
596 icon={<X />}
597 className="px-1.5"
598 onClick={() => setSelectedUsers(new Set([]))}
599 tooltip={{ content: { side: 'bottom', text: 'Cancel selection' } }}
600 />
601 </div>
602 ) : (
603 <>
604 <div className="flex flex-wrap items-center gap-2">
605 <UsersSearch
606 ref={searchInputRef}
607 search={search}
608 setSearch={setSearch}
609 improvedSearchEnabled={improvedSearchEnabled}
610 telemetryProps={telemetryProps}
611 telemetryGroups={telemetryGroups}
612 onSelectFilterColumn={(value) => {
613 if (value === 'freeform') {
614 if (isCountWithinThresholdForSortBy) {
615 updateStorageFilter(value)
616 } else {
617 setShowFreeformWarning(true)
618 }
619 } else {
620 updateStorageFilter(value)
621 }
622 }}
623 />
624
625 {showUserTypeFilter &&
626 (specificFilterColumn === 'freeform' || improvedSearchEnabled) && (
627 <Select
628 value={filterUserType}
629 onValueChange={(val) => {
630 setFilterUserType(val as Filter)
631 sendEvent({
632 action: 'auth_users_search_submitted',
633 properties: {
634 trigger: 'user_type_filter',
635 ...telemetryProps,
636 user_type: val,
637 },
638 groups: telemetryGroups,
639 })
640 }}
641 >
642 <SelectTrigger
643 size="tiny"
644 className={cn(
645 'w-[140px] bg-transparent!',
646 filterUserType === 'all' && 'border-dashed'
647 )}
648 >
649 <SelectValue />
650 </SelectTrigger>
651 <SelectContent>
652 <SelectGroup>
653 <SelectItem value="all" className="text-xs">
654 All users
655 </SelectItem>
656 <SelectItem value="verified" className="text-xs">
657 Verified users
658 </SelectItem>
659 <SelectItem value="unverified" className="text-xs">
660 Unverified users
661 </SelectItem>
662 <SelectItem value="anonymous" className="text-xs">
663 Anonymous users
664 </SelectItem>
665 </SelectGroup>
666 </SelectContent>
667 </Select>
668 )}
669
670 {showProviderFilter &&
671 (specificFilterColumn === 'freeform' || improvedSearchEnabled) && (
672 <FilterPopover
673 name="Provider"
674 options={PROVIDER_FILTER_OPTIONS}
675 labelKey="name"
676 valueKey="value"
677 iconKey="icon"
678 activeOptions={selectedProviders}
679 labelClass="text-xs"
680 maxHeightClass="h-[190px]"
681 className="w-52"
682 onSaveFilters={(providers) => {
683 setSelectedProviders(providers)
684 sendEvent({
685 action: 'auth_users_search_submitted',
686 properties: {
687 trigger: 'provider_filter',
688 ...telemetryProps,
689 providers,
690 },
691 groups: telemetryGroups,
692 })
693 }}
694 />
695 )}
696
697 <div className="border-r border-strong h-6" />
698
699 <FilterPopover
700 name={selectedColumns.length === 0 ? 'All columns' : 'Columns'}
701 title="Select columns to show"
702 buttonType={selectedColumns.length === 0 ? 'dashed' : 'default'}
703 options={userTableColumns.slice(1)} // Ignore user image column
704 labelKey="name"
705 valueKey="id"
706 labelClass="text-xs"
707 maxHeightClass="h-[190px]"
708 clearButtonText="Reset"
709 activeOptions={selectedColumns}
710 onSaveFilters={(value) => {
711 // When adding back hidden columns:
712 // (1) width set to default value if any
713 // (2) they will just get appended to the end
714 // (3) If "clearing", reset order of the columns to original
715
716 let updatedConfig = (columnConfiguration ?? []).slice()
717 if (value.length === 0) {
718 updatedConfig = userTableColumns.map((c) => ({ id: c.id, width: c.width }))
719 } else {
720 value.forEach((col) => {
721 const hasExisting = updatedConfig.find((c) => c.id === col)
722 if (!hasExisting)
723 updatedConfig.push({
724 id: col,
725 width: userTableColumns.find((c) => c.id === col)?.width,
726 })
727 })
728 }
729
730 const updatedColumns = formatUserColumns({
731 specificFilterColumn,
732 columns: userTableColumns,
733 config: updatedConfig,
734 users: users ?? [],
735 visibleColumns: value,
736 setSortByValue: updateSortByValue,
737 onSelectDeleteUser: setSelectedUserToDelete,
738 onSelectImpersonateUser,
739 })
740
741 setSelectedColumns(value)
742 setColumns(updatedColumns)
743 saveColumnConfiguration('toggle', { columns: updatedColumns })
744 }}
745 />
746
747 <SortDropdown
748 specificFilterColumn={specificFilterColumn}
749 sortColumn={sortColumn}
750 sortOrder={sortOrder}
751 sortByValue={sortByValue}
752 setSortByValue={(value) => {
753 const [sortColumn, sortOrder] = value.split(':')
754 updateSortByValue(value)
755 sendEvent({
756 action: 'auth_users_search_submitted',
757 properties: {
758 trigger: 'sort_change',
759 ...telemetryProps,
760 sort_column: sortColumn,
761 sort_order: sortOrder,
762 },
763 groups: telemetryGroups,
764 })
765 }}
766 showSortByEmail={showSortByEmail}
767 showSortByPhone={showSortByPhone}
768 improvedSearchEnabled={improvedSearchEnabled}
769 />
770 </div>
771
772 <div className="flex items-center gap-x-2">
773 <ButtonTooltip
774 size="tiny"
775 icon={<RefreshCw />}
776 type="default"
777 className="w-7"
778 loading={isRefetching && !isFetchingNextPage}
779 onClick={handleRefresh}
780 tooltip={{ content: { side: 'bottom', text: 'Refresh' } }}
781 aria-label="Refresh"
782 />
783 <AddUserDropdown />
784 </div>
785 </>
786 )}
787 </div>
788 <LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
789 <ResizablePanelGroup
790 orientation="horizontal"
791 className="relative flex grow bg-alternative min-h-0"
792 autoSaveId="query-performance-layout-v1"
793 >
794 <ResizablePanel>
795 <div className="flex flex-col w-full h-full">
796 <DataGrid
797 ref={gridRef}
798 className="grow border-t-0"
799 rowHeight={44}
800 headerRowHeight={36}
801 columns={columns}
802 rows={formatUsersData(users ?? [])}
803 rowClass={(row) => {
804 const isSelected = row.id === selectedUser
805 return [
806 `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
807 '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-hidden [&>.rdg-cell]:shadow-none',
808 '[&>.rdg-cell:first-child>div]:ml-4',
809 ].join(' ')
810 }}
811 rowKeyGetter={(row) => row.id}
812 selectedRows={selectedUsers}
813 onScroll={handleScroll}
814 onSelectedRowsChange={(rows) => {
815 if (rows.size > MAX_BULK_DELETE) {
816 toast(`Only up to ${MAX_BULK_DELETE} users can be selected at a time`)
817 } else setSelectedUsers(rows)
818 }}
819 onCellKeyDown={onCellKeyDown}
820 onColumnResize={(idx, width) => saveColumnConfiguration('resize', { idx, width })}
821 onColumnsReorder={(source, target) => {
822 const sourceIdx = columns.findIndex((col) => col.key === source)
823 const targetIdx = columns.findIndex((col) => col.key === target)
824
825 const updatedColumns = swapColumns(columns, sourceIdx, targetIdx)
826 setColumns(updatedColumns)
827
828 saveColumnConfiguration('reorder', { columns: updatedColumns })
829 }}
830 renderers={{
831 renderRow(id, props) {
832 return (
833 <Row
834 {...props}
835 onClick={() => {
836 const user = users.find((u) => u.id === id)
837 if (user) {
838 const idx = users.indexOf(user)
839 if (props.row.id) {
840 setSelectedId(props.row.id)
841 gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
842 }
843 }
844 }}
845 />
846 )
847 },
848 noRowsFallback: isPendingProject ? (
849 <div className="absolute top-14 px-6 w-full">
850 <GenericSkeletonLoader />
851 </div>
852 ) : project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY || isProjectError ? (
853 <div className="absolute top-14 px-6 flex flex-col items-center justify-center w-full">
854 <AlertError
855 subject="Unable to load users"
856 error={{
857 message:
858 'Could not connect to the database. Please check your project status.',
859 }}
860 />
861 </div>
862 ) : isPending ? (
863 <div className="absolute top-14 px-6 w-full">
864 <GenericSkeletonLoader />
865 </div>
866 ) : isError ? (
867 <div className="absolute top-14 px-6 flex flex-col items-center justify-center w-full">
868 <AlertError subject="Failed to retrieve users" error={error} />
869 </div>
870 ) : isSuccess ? (
871 <div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
872 <Users className="text-foreground-lighter" strokeWidth={1} />
873 <div className="text-center">
874 <p className="text-foreground">
875 {filterUserType !== 'all' || filterKeywords.length > 0
876 ? 'No users found'
877 : 'No users in your project'}
878 </p>
879 <p className="text-foreground-light">
880 {filterUserType !== 'all' || filterKeywords.length > 0
881 ? 'There are currently no users based on the filters applied'
882 : 'There are currently no users who signed up to your project'}
883 </p>
884 </div>
885 </div>
886 ) : null,
887 }}
888 />
889 </div>
890 </ResizablePanel>
891 {!!selectedId && <UserPanel />}
892 </ResizablePanelGroup>
893
894 <UsersFooter
895 filter={filterUserType}
896 filterKeywords={filterKeywords}
897 selectedProviders={selectedProviders}
898 specificFilterColumn={specificFilterColumn}
899 />
900 </div>
901
902 <ConfirmationModal
903 visible={showDeleteModal}
904 variant="destructive"
905 title={`Confirm to delete ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`}
906 loading={isDeletingUsers}
907 confirmLabel="Delete"
908 onCancel={() => setShowDeleteModal(false)}
909 onConfirm={() => handleDeleteUsers()}
910 alert={{
911 title: `Deleting ${selectedUsers.size === 1 ? 'a user' : 'users'} is irreversible`,
912 description: `This will remove the selected ${selectedUsers.size === 1 ? '' : `${selectedUsers.size} `}user${selectedUsers.size > 1 ? 's' : ''} from the project and all associated data.`,
913 }}
914 >
915 <p className="text-sm text-foreground-light">
916 This is permanent! Are you sure you want to delete the{' '}
917 {selectedUsers.size === 1 ? '' : `selected ${selectedUsers.size} `}user
918 {selectedUsers.size > 1 ? 's' : ''}
919 {selectedUsers.size === 1 ? (
920 <span className="text-foreground">
921 {' '}
922 {selectedUserFromCheckbox?.email ?? selectedUserFromCheckbox?.phone ?? 'this user'}
923 </span>
924 ) : null}
925 ?
926 </p>
927 </ConfirmationModal>
928
929 <ConfirmationModal
930 size="medium"
931 variant="warning"
932 visible={showFreeformWarning}
933 confirmLabel="Confirm"
934 title="Confirm to search across all columns"
935 onConfirm={() => {
936 updateStorageFilter('freeform')
937 setShowFreeformWarning(false)
938 }}
939 onCancel={() => setShowFreeformWarning(false)}
940 alert={{
941 base: { variant: 'warning' },
942 title: 'Searching across all columns is not recommended with many users',
943 description:
944 '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.',
945 }}
946 >
947 <p className="text-foreground-light text-sm">
948 This will allow you to search across user ID, email, phone number, and display name
949 through a single input field. You will also be able to filter users by provider and sort
950 on users across different columns.
951 </p>
952 </ConfirmationModal>
953
954 <ConfirmationModal
955 size="medium"
956 visible={showCreateIndexesModal}
957 confirmLabel="Upgrade search"
958 title="Upgrade to improved search"
959 onConfirm={() => {
960 handleEnableUserSearchIndexes()
961 setShowCreateIndexesModal(false)
962 }}
963 onCancel={() => setShowCreateIndexesModal(false)}
964 alert={{
965 title: 'Improved search experience',
966 description:
967 'This will create indexes to enable faster and more reliable searching, sorting, and filtering of your users.',
968 }}
969 >
970 <ul className="text-sm list-disc pl-4 my-3 flex flex-col gap-2">
971 <li className="marker:text-foreground-light">
972 Creating these indexes may temporarily impact database performance.
973 </li>
974 <li className="marker:text-foreground-light">
975 Depending on the number of users, this may take some time to complete.
976 </li>
977 <li className="marker:text-foreground-light">
978 You can continue using the Auth Users page while the indexes are being created, but
979 improvements will only take effect once complete.
980 </li>
981 <li className="marker:text-foreground-light">
982 You can monitor the progress in the{' '}
983 <InlineLink
984 href={`/project/${projectRef}/logs/explorer?q=${encodeURI(INDEX_WORKER_LOGS_SEARCH_STRING)}`}
985 target="_blank"
986 >
987 project logs
988 </InlineLink>
989 . If you encounter any issues, please contact Briven support for assistance.
990 </li>
991 </ul>
992 </ConfirmationModal>
993
994 {/* [Joshen] For deleting via context menu, the dialog above is dependent on the selectedUsers state */}
995 <DeleteUserModal
996 visible={!!selectedUserToDelete}
997 selectedUser={selectedUserToDelete}
998 onClose={() => {
999 setSelectedUserToDelete(undefined)
1000 cleanPointerEventsNoneOnBody()
1001 }}
1002 onDeleteSuccess={() => {
1003 if (selectedUserToDelete?.id === selectedUser) setSelectedId(null)
1004 setSelectedUserToDelete(undefined)
1005 cleanPointerEventsNoneOnBody(500)
1006 }}
1007 />
1008 </>
1009 )
1010}