UsersV2.tsx 39 KB

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