diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1d8b37fb1e6c4..87eac90f119ad 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -11,7 +11,7 @@ /apps/www/public/images/blog @supabase/marketing /apps/www/lib/redirects.js -/docker/ @supabase/dev-workflows +/docker/ @supabase/dev-workflows @aantti /apps/studio/csp.js @supabase/security /apps/studio/components/interfaces/Billing/Payment @supabase/security diff --git a/apps/docs/content/troubleshooting/how-to-delete-vercel-linked-projects-9d08aa.mdx b/apps/docs/content/troubleshooting/how-to-delete-vercel-linked-projects-9d08aa.mdx index 5b13c6bd86ef1..3de2f2e4563c0 100644 --- a/apps/docs/content/troubleshooting/how-to-delete-vercel-linked-projects-9d08aa.mdx +++ b/apps/docs/content/troubleshooting/how-to-delete-vercel-linked-projects-9d08aa.mdx @@ -18,11 +18,7 @@ This method requires two steps - first delete in Supabase, then clean up in Verc Go to your [project settings](/dashboard/project/_/settings/general) and click **"Delete project"**. This permanently removes your database and marks it as "uninstalled" in Vercel. - Delete project button in Supabase project settings + ![Delete project button in Supabase project settings](/docs/img/troubleshooting/54428354-a1f4-4c44-bd03-d87f4b925d94.png) 2. **Clean up in Vercel** @@ -32,27 +28,15 @@ This method requires two steps - first delete in Supabase, then clean up in Verc - Go to the **Storage** tab - Click on the Supabase project (it will show as "uninstalled") - Uninstalled Supabase project in Vercel Storage + ![Uninstalled Supabase project in Vercel Storage](/docs/img/troubleshooting/cf450bca-aad3-4783-ae3b-5cc83e0afa54.png) - Scroll down and find the **Settings** tab in the sidebar - Vercel project settings navigation + ![Vercel project settings navigation](/docs/img/troubleshooting/da70bc33-f123-4567-8901-abcdef123456.png) - Click **Delete Database** to completely remove it - Delete Database button in Vercel settings{' '} + ![Delete Database button in Vercel settings](/docs/img/troubleshooting/c4e7b193-4567-8901-abcd-ef1234567890.png) ### Method 2: Delete directly from Vercel (removes from both platforms) @@ -70,21 +54,13 @@ This method deletes the project from both Vercel and Supabase in one action. Find and click the **Settings** tab in the sidebar. - Vercel project settings navigation + ![Vercel project settings navigation](/docs/img/troubleshooting/da70bc33-f123-4567-8901-abcdef123456.png) 4. **Delete the project** Scroll down and click **Delete Database**. This removes the project from both Vercel and Supabase simultaneously. - Delete Database button in Vercel settings + ![Delete Database button in Vercel settings](/docs/img/troubleshooting/c4e7b193-4567-8901-abcd-ef1234567890.png) ## Transferring projects from Vercel organizations @@ -100,11 +76,7 @@ If you want to move a Vercel-linked project to a different organization: Go to your [project settings](/dashboard/project/_/settings/general) and look for **"Transfer Project"**. Transfer your project to a different organization. - Transfer project option in Supabase project settings + ![Transfer project option in Supabase project settings](/docs/img/troubleshooting/627c1a3e-153e-40b5-9a91-a4f7bd7673a4.png) Note: The owner must be a member of both the source and target organizations. Transferring a Vercel-managed project will remove its linked Vercel storage from your team. @@ -133,21 +105,13 @@ Delete all associated projects before deleting the organization. Go to your [organization settings](/dashboard/org/_/general) and click **"Delete organization in Vercel Marketplace"**. This opens the Vercel settings page. - Delete organization in Vercel Marketplace button + ![Delete organization in Vercel Marketplace button](/docs/img/troubleshooting/ff01bba8-abcd-ef12-3456-7890abcdef12.png) 2. **Uninstall integration** Scroll down and click **Uninstall Integration** to fully remove the organization and disconnect it from Vercel. - Uninstall Integration button in Vercel settings + ![Uninstall Integration button in Vercel settings](/docs/img/troubleshooting/f294a4d6-ef12-3456-7890-abcdef123456.png) ## Important notes diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 665fe64bb68ed..959a9be6bf505 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -10,6 +10,7 @@ Aleksi Immonen Alexander Korotkov Amy Q Andrew Valleteau +Andrey A Angelico de los Reyes Ant Wilson Ariuna K @@ -50,6 +51,7 @@ Dustin Keib Eduardo Gurgel Egor Romanov Eleftheria Trivyzaki +Eliot Whalan Emmett Folger Eric Kharitonashvili Etienne Stalmans @@ -60,6 +62,7 @@ Francesco Sansalvadore Greg Kress Greg P Greg Richardson +Gregor Vand Guilherme Souza Hannah Bowers Hardik Maheshwari diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index 2b9304dc0d86d..4c7170c1868a6 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -1,3 +1,4 @@ +import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate' import { ArrowLeft, ArrowRight, HelpCircle } from 'lucide-react' import { useEffect, useState } from 'react' @@ -5,7 +6,7 @@ import { useParams } from 'common' import { useTableFilter } from 'components/grid/hooks/useTableFilter' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTable } from 'data/table-editor/table-editor-types' -import { THRESHOLD_COUNT, useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query' +import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { RoleImpersonationState } from 'lib/role-impersonation' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' diff --git a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx new file mode 100644 index 0000000000000..1d7a29d3ce667 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx @@ -0,0 +1,112 @@ +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { ArrowDownNarrowWide, ArrowDownWideNarrow } from 'lucide-react' + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from 'ui' + +interface SortDropdownProps { + specificFilterColumn: string + sortColumn: string + sortOrder: string + sortByValue: string + showSortByEmail: boolean + showSortByPhone: boolean + setSortByValue: (value: string) => void +} + +export const SortDropdown = ({ + specificFilterColumn, + sortColumn, + sortOrder, + sortByValue, + showSortByEmail, + showSortByPhone, + setSortByValue, +}: SortDropdownProps) => { + if (specificFilterColumn !== 'freeform') { + return ( + } + tooltip={{ + content: { + side: 'bottom', + className: 'w-80 text-center', + text: ( + <> + Sorting cannot be changed which searching on a specific column. If you'd like to + sort on other columns, change the search to{' '} + all columns from the header. + + ), + }, + }} + > + Sorted by user ID + + ) + } + + return ( + + + + + + + + Sort by user ID + + Ascending + Descending + + + + Sort by created at + + Ascending + Descending + + + + Sort by last sign in at + + Ascending + Descending + + + {showSortByEmail && ( + + Sort by email + + Ascending + Descending + + + )} + {showSortByPhone && ( + + Sort by phone + + Ascending + Descending + + + )} + + + + ) +} diff --git a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx index b24c16e3f6c4c..5dc5396ae0dac 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx @@ -1,5 +1,6 @@ import { ExternalLink, RefreshCw } from 'lucide-react' import Link from 'next/link' +import { useQueryState } from 'nuqs' import { useEffect } from 'react' import { useParams } from 'common' @@ -21,6 +22,7 @@ interface UserLogsProps { export const UserLogs = ({ user }: UserLogsProps) => { const { ref } = useParams() const { filters, setFilters } = useLogsUrlState() + const [_, setFiltersValue] = useQueryState('f') const { logData: authLogs, @@ -36,6 +38,10 @@ export const UserLogs = ({ user }: UserLogsProps) => { useEffect(() => { if (user.id) setFilters({ ...filters, search_query: user.id }) + + return () => { + setFiltersValue(null) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user.id]) diff --git a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx index 730ed2d3ccc93..ca8c2681e14d6 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx @@ -110,7 +110,7 @@ export const UserPanel = ({ selectedUser, onClose }: UserPanelProps) => { Clear - + {JSON.stringify(filteredProperties, null, 2)} diff --git a/apps/studio/components/interfaces/Auth/Users/Users.constants.ts b/apps/studio/components/interfaces/Auth/Users/Users.constants.ts index daa7050d141fb..2b7a1dc4d8a6d 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.constants.ts +++ b/apps/studio/components/interfaces/Auth/Users/Users.constants.ts @@ -1,6 +1,13 @@ import { BASE_PATH } from 'lib/constants' import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation' +export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous' + +export const UUIDV4_LEFT_PREFIX_REGEX = + /^(?:[0-9a-f]{1,8}|[0-9a-f]{8}-|[0-9a-f]{8}-[0-9a-f]{1,4}|[0-9a-f]{8}-[0-9a-f]{4}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{0,3}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{0,3}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{0,12})$/i + +export const PHONE_NUMBER_LEFT_PREFIX_REGEX = /^[+]?[0-9]{0,15}$/ + export const PANEL_PADDING = 'px-5 py-5' // [Joshen] Temporary fix as bulk delete will fire n requests since Auth + API do not have a bulk delete endpoint yet diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx index e67174d8bef86..e4dd34337f699 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx +++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx @@ -250,6 +250,7 @@ export function getAvatarUrl(user: User): string | undefined { } export const formatUserColumns = ({ + specificFilterColumn, columns, config, users, @@ -257,6 +258,7 @@ export const formatUserColumns = ({ setSortByValue, onSelectDeleteUser, }: { + specificFilterColumn: string columns: UsersTableColumn[] config: ColumnConfiguration[] users: User[] @@ -283,7 +285,13 @@ export const formatUserColumns = ({ // to support - the component is ready as such: Just pass selectedUsers and allRowsSelected as props from parent // if (col.id === 'img') return undefined - return + return ( + + ) }, renderCell: ({ row }) => { // This is actually a valid React component, so we can use hooks here diff --git a/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx b/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx new file mode 100644 index 0000000000000..eee1b8ed158ba --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx @@ -0,0 +1,134 @@ +import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate' +import { HelpCircle, Loader2 } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { useParams } from 'common' +import { formatEstimatedCount } from 'components/grid/components/footer/pagination/Pagination.utils' +import { useUsersCountQuery } from 'data/auth/users-count-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { Filter } from './Users.constants' + +interface UsersFooterProps { + filter: Filter + filterKeywords: string + selectedProviders: string[] + specificFilterColumn: string +} + +export const UsersFooter = ({ + filter, + filterKeywords, + selectedProviders, + specificFilterColumn, +}: UsersFooterProps) => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + const [forceExactCount, setForceExactCount] = useState(false) + const [showFetchExactCountModal, setShowFetchExactCountModal] = useState(false) + + const { + data: countData, + isLoading: isLoadingCount, + isFetching: isFetchingCount, + isSuccess: isSuccessCount, + } = useUsersCountQuery( + { + projectRef, + connectionString: project?.connectionString, + keywords: filterKeywords, + filter: filter === 'all' ? undefined : filter, + providers: selectedProviders, + forceExactCount, + }, + { keepPreviousData: true } + ) + const totalUsers = countData?.count ?? 0 + + useEffect(() => { + if (isSuccessCount && specificFilterColumn === 'freeform') { + setForceExactCount(countData.is_estimate && countData.count <= THRESHOLD_COUNT) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccessCount, specificFilterColumn]) + + return ( + <> +
+
+ {isLoadingCount || isFetchingCount || countData === undefined ? ( + + Loading... + + ) : ( + <> + + Total:{' '} + {countData?.is_estimate + ? formatEstimatedCount(totalUsers) + : totalUsers.toLocaleString()}{' '} + user{totalUsers !== 1 ? 's' : ''} + {countData?.is_estimate && ' (estimated)'} + + {countData?.is_estimate && ( + + +
+
+ setShowFetchExactCountModal(false)} + onConfirm={() => { + setForceExactCount(true) + setShowFetchExactCountModal(false) + }} + > +

+ Your project has more than {THRESHOLD_COUNT.toLocaleString()} users, and fetching the + exact count may cause performance issues on your database. +

+
+ + ) +} diff --git a/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx b/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx index fc6544cd6fc4d..13a085c76ffca 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx @@ -45,9 +45,11 @@ export const SelectHeaderCell = ({ export const HeaderCell = ({ col, + specificFilterColumn, setSortByValue, }: { col: any + specificFilterColumn: string setSortByValue: (value: string) => void }) => { const ref = useRef(0) @@ -62,7 +64,7 @@ export const HeaderCell = ({

{col.name}

- {['created_at', 'email', 'phone'].includes(col.id) && ( + {specificFilterColumn === 'freeform' && ['created_at', 'email', 'phone'].includes(col.id) && ( { diff --git a/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx b/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx new file mode 100644 index 0000000000000..9726cc45d60ff --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx @@ -0,0 +1,118 @@ +import { X } from 'lucide-react' +import { SetStateAction } from 'react' + +import { + Button, + cn, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectGroup_Shadcn_, + SelectItem_Shadcn_, + SelectSeparator_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' + +interface UsersSearchProps { + search: string + searchInvalid: boolean + specificFilterColumn: 'id' | 'email' | 'phone' | 'freeform' + setSearch: (value: SetStateAction) => void + setFilterKeywords: (value: SetStateAction) => void + setSpecificFilterColumn: (value: 'id' | 'email' | 'phone' | 'freeform') => void +} + +export const UsersSearch = ({ + search, + searchInvalid, + specificFilterColumn, + setSearch, + setFilterKeywords, + setSpecificFilterColumn, +}: UsersSearchProps) => { + return ( +
+
+ Search +
+ + setSpecificFilterColumn(v as typeof specificFilterColumn)} + > + + + + + + + User ID + + + Email address + + + Phone number + + + + All columns + + + + + + 1 && 'pr-6' + )} + placeholder={ + specificFilterColumn === 'freeform' + ? 'Search by user ID, email, phone or name' + : `Search by ${specificFilterColumn === 'id' ? 'User ID' : specificFilterColumn === 'email' ? 'Email' : 'Phone'}` + } + value={search} + onChange={(e) => { + const value = e.target.value.replace(/\s+/g, '').toLowerCase() + setSearch(value) + }} + onKeyDown={(e) => { + if (e.code === 'Enter' || e.code === 'NumpadEnter') { + setSearch((s) => { + if (s && specificFilterColumn === 'phone' && !s.startsWith('+')) { + return `+${s}` + } else { + return s + } + }) + if (!searchInvalid) setFilterKeywords(search.trim().toLocaleLowerCase()) + } + }} + actions={ + search ? ( +
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index fd4f05d91284c..3bf137296a391 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -1,16 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import AwesomeDebouncePromise from 'awesome-debounce-promise' -import { - ArrowDown, - ArrowUp, - HelpCircle, - Loader2, - RefreshCw, - Search, - Trash, - Users, - X, -} from 'lucide-react' +import { RefreshCw, Trash, Users, X } from 'lucide-react' import { UIEvent, useEffect, useMemo, useRef, useState } from 'react' import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' import { toast } from 'sonner' @@ -24,9 +14,7 @@ import { FilterPopover } from 'components/ui/FilterPopover' import { FormHeader } from 'components/ui/Forms/FormHeader' import { authKeys } from 'data/auth/keys' import { useUserDeleteMutation } from 'data/auth/user-delete-mutation' -import { useUsersCountQuery } from 'data/auth/users-count-query' import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query' -import { THRESHOLD_COUNT } from 'data/table-rows/table-rows-count-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -34,14 +22,6 @@ import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { Button, cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, LoadingLine, ResizablePanel, ResizablePanelGroup, @@ -51,28 +31,26 @@ import { SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, - Tooltip, - TooltipContent, - TooltipTrigger, } from 'ui' -import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { AddUserDropdown } from './AddUserDropdown' import { DeleteUserModal } from './DeleteUserModal' +import { SortDropdown } from './SortDropdown' import { UserPanel } from './UserPanel' import { ColumnConfiguration, + Filter, MAX_BULK_DELETE, + PHONE_NUMBER_LEFT_PREFIX_REGEX, PROVIDER_FILTER_OPTIONS, USERS_TABLE_COLUMNS, + UUIDV4_LEFT_PREFIX_REGEX, } from './Users.constants' import { formatUserColumns, formatUsersData } from './Users.utils' +import { UsersFooter } from './UsersFooter' +import { UsersSearch } from './UsersSearch' -export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous' - -// [Joshen] Just naming it as V2 as its a rewrite of the old one, to make it easier for reviews -// Can change it to remove V2 thereafter export const UsersV2 = () => { const queryClient = useQueryClient() const { ref: projectRef } = useParams() @@ -105,22 +83,24 @@ export const UsersV2 = () => { } }, [showEmailPhoneColumns]) + const [specificFilterColumn, setSpecificFilterColumn] = useState< + 'id' | 'email' | 'phone' | 'freeform' + >('id' as const) + const [columns, setColumns] = useState[]>([]) const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') const [filterKeywords, setFilterKeywords] = useState('') const [selectedColumns, setSelectedColumns] = useState([]) const [selectedProviders, setSelectedProviders] = useState([]) - const [sortByValue, setSortByValue] = useState('created_at:desc') + const [sortByValue, setSortByValue] = useState('id:asc') const [selectedUser, setSelectedUser] = useState() const [selectedUsers, setSelectedUsers] = useState>(new Set([])) const [selectedUserToDelete, setSelectedUserToDelete] = useState() const [showDeleteModal, setShowDeleteModal] = useState(false) const [isDeletingUsers, setIsDeletingUsers] = useState(false) - - const [forceExactCount, setForceExactCount] = useState(false) - const [showFetchExactCountModal, setShowFetchExactCountModal] = useState(false) + const [showFreeformWarning, setShowFreeformWarning] = useState(false) const [ columnConfiguration, @@ -149,10 +129,13 @@ export const UsersV2 = () => { projectRef, connectionString: project?.connectionString, keywords: filterKeywords, - filter: filter === 'all' ? undefined : filter, + filter: specificFilterColumn !== 'freeform' || filter === 'all' ? undefined : filter, providers: selectedProviders, - sort: sortColumn as 'created_at' | 'email' | 'phone', + sort: sortColumn as 'id' | 'created_at' | 'email' | 'phone', order: sortOrder as 'asc' | 'desc', + ...(specificFilterColumn !== 'freeform' + ? { column: specificFilterColumn } + : { column: undefined }), }, { keepPreviousData: Boolean(filterKeywords), @@ -162,32 +145,12 @@ export const UsersV2 = () => { } ) - const { - data: countData, - refetch: refetchCount, - isLoading: isLoadingCount, - } = useUsersCountQuery({ - projectRef, - connectionString: project?.connectionString, - keywords: filterKeywords, - filter: filter === 'all' ? undefined : filter, - providers: selectedProviders, - forceExactCount, - }) - const { mutateAsync: deleteUser } = useUserDeleteMutation() - const totalUsers = countData?.count ?? 0 const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages]) // [Joshen] Only relevant for when selecting one user only const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0]) - const formatEstimatedCount = (count: number) => { - if (count >= 1e6) return `${(count / 1e6).toFixed(1)}M` - if (count >= 1e3) return `${(count / 1e3).toFixed(1)}K` - return count.toString() - } - const handleScroll = (event: UIEvent) => { const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft xScroll.current = event.currentTarget.scrollLeft @@ -204,11 +167,6 @@ export const UsersV2 = () => { fetchNextPage() } - const clearSearch = () => { - setSearch('') - setFilterKeywords('') - } - const swapColumns = (data: any[], sourceIdx: number, targetIdx: number) => { const updatedColumns = data.slice() const [removed] = updatedColumns.splice(sourceIdx, 1) @@ -252,10 +210,7 @@ export const UsersV2 = () => { userIds.map((id) => deleteUser({ projectRef, userId: id, skipInvalidation: true })) ) // [Joshen] Skip invalidation within RQ to prevent multiple requests, then invalidate once at the end - await Promise.all([ - queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)), - queryClient.invalidateQueries(authKeys.usersCount(projectRef)), - ]) + await Promise.all([queryClient.invalidateQueries(authKeys.usersInfinite(projectRef))]) toast.success( `Successfully deleted the selected ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}` ) @@ -277,6 +232,7 @@ export const UsersV2 = () => { (isErrorStorage && (errorStorage as Error).message.includes('data is undefined'))) ) { const columns = formatUserColumns({ + specificFilterColumn, columns: userTableColumns, config: columnConfiguration ?? [], users: users ?? [], @@ -298,8 +254,16 @@ export const UsersV2 = () => { errorStorage, users, selectedUsers, + specificFilterColumn, ]) + const searchInvalid = + !search || specificFilterColumn === 'freeform' || specificFilterColumn === 'email' + ? false + : specificFilterColumn === 'id' + ? !search.match(UUIDV4_LEFT_PREFIX_REGEX) + : !search.match(PHONE_NUMBER_LEFT_PREFIX_REGEX) + return ( <>
@@ -321,45 +285,36 @@ export const UsersV2 = () => { ) : ( <>
- } - placeholder="Search email, phone or UID" - value={search} - onChange={(e) => setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.code === 'Enter' || e.code === 'NumpadEnter') { - setSearch(search.trim()) - setFilterKeywords(search.trim().toLocaleLowerCase()) + { + setFilterKeywords(s) + setSelectedUser(undefined) + }} + setSpecificFilterColumn={(value) => { + if (value === 'freeform') { + setShowFreeformWarning(true) + } else { + setSpecificFilterColumn(value) } }} - actions={[ - search && ( - - - - - - Sort by created at - - - Ascending - - - Descending - - - - - Sort by last sign in at - - - Ascending - - - Descending - - - - {showSortByEmail && ( - - Sort by email - - - Ascending - - - Descending - - - - )} - {showSortByPhone && ( - - Sort by phone - - - Ascending - - - Descending - - - - )} - - - +
{isNewAPIDocsEnabled && ( )} - + onClick={() => refetch()} + tooltip={{ content: { side: 'bottom', text: 'Refresh' } }} + />
@@ -619,50 +522,12 @@ export const UsersV2 = () => { )} -
-
- {isLoadingCount ? ( - 'Loading user count...' - ) : ( - <> - - Total:{' '} - {countData?.is_estimate - ? formatEstimatedCount(totalUsers) - : totalUsers.toLocaleString()}{' '} - user{totalUsers !== 1 ? 's' : ''} - {countData?.is_estimate && ' (estimated)'} - - {countData?.is_estimate && ( - - -
- {(isLoading || isRefetching || isFetchingNextPage) && ( - - Loading... - - )} -
+
{

+ { + setSpecificFilterColumn('freeform') + setShowFreeformWarning(false) + }} + onCancel={() => setShowFreeformWarning(false)} + alert={{ + base: { variant: 'warning' }, + title: 'Searching across all columns is not recommended', + description: + 'This may adversely impact your database, in particular if your project has a large number of users - use with caution.', + }} + > +

+ 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. +

+
+ {/* [Joshen] For deleting via context menu, the dialog above is dependent on the selectedUsers state */} { cleanPointerEventsNoneOnBody(500) }} /> - - setShowFetchExactCountModal(false)} - onConfirm={() => { - setForceExactCount(true) - setShowFetchExactCountModal(false) - }} - > -

- Your project has more than {THRESHOLD_COUNT.toLocaleString()} users, and fetching the - exact count may cause performance issues on your database. -

-
) } diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx index ffd7e5ba2b360..1cea7a375c84b 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx @@ -40,7 +40,7 @@ export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archi const { data: organizations } = useOrganizationsQuery() const { data } = useProjectsInfiniteQuery( { search: search.length === 0 ? search : debouncedSearch }, - { keepPreviousData: true } + { keepPreviousData: true, enabled: open } ) const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || [] const projectCount = data?.pages[0].pagination.count ?? 0 diff --git a/apps/studio/components/ui/OrganizationProjectSelector.tsx b/apps/studio/components/ui/OrganizationProjectSelector.tsx index f1a12adb50c09..678f11bfdc428 100644 --- a/apps/studio/components/ui/OrganizationProjectSelector.tsx +++ b/apps/studio/components/ui/OrganizationProjectSelector.tsx @@ -86,7 +86,7 @@ export const OrganizationProjectSelector = ({ fetchNextPage, } = useOrgProjectsInfiniteQuery( { slug, search: search.length === 0 ? search : debouncedSearch }, - { keepPreviousData: true } + { enabled: open, keepPreviousData: true } ) const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || [] diff --git a/apps/studio/data/auth/keys.ts b/apps/studio/data/auth/keys.ts index de6e3852ea7ee..86fa936c13b8d 100644 --- a/apps/studio/data/auth/keys.ts +++ b/apps/studio/data/auth/keys.ts @@ -8,6 +8,14 @@ export const authKeys = { } ) => ['projects', projectRef, 'users', ...(params ? [params] : [])] as const, + usersQuery: ( + projectRef: string | undefined, + params?: { + query: string + startAt: string + } + ) => ['projects', projectRef, 'users-query', ...(params ? [params] : [])] as const, + usersInfinite: ( projectRef: string | undefined, params?: { diff --git a/apps/studio/data/auth/user-create-mutation.ts b/apps/studio/data/auth/user-create-mutation.ts index 4ff0ea43f4da5..533c8e2df418b 100644 --- a/apps/studio/data/auth/user-create-mutation.ts +++ b/apps/studio/data/auth/user-create-mutation.ts @@ -45,10 +45,7 @@ export const useUserCreateMutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables - await Promise.all([ - queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)), - queryClient.invalidateQueries(authKeys.usersCount(projectRef)), - ]) + await Promise.all([queryClient.invalidateQueries(authKeys.usersInfinite(projectRef))]) await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/data/auth/user-delete-mutation.ts b/apps/studio/data/auth/user-delete-mutation.ts index 819235982d662..0b67188f05238 100644 --- a/apps/studio/data/auth/user-delete-mutation.ts +++ b/apps/studio/data/auth/user-delete-mutation.ts @@ -38,10 +38,7 @@ export const useUserDeleteMutation = ({ const { projectRef, skipInvalidation = false } = variables if (!skipInvalidation) { - await Promise.all([ - queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)), - queryClient.invalidateQueries(authKeys.usersCount(projectRef)), - ]) + await Promise.all([queryClient.invalidateQueries(authKeys.usersInfinite(projectRef))]) } await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/auth/user-invite-mutation.ts b/apps/studio/data/auth/user-invite-mutation.ts index 0466ab0ed0944..42bff571c6d76 100644 --- a/apps/studio/data/auth/user-invite-mutation.ts +++ b/apps/studio/data/auth/user-invite-mutation.ts @@ -37,10 +37,7 @@ export const useUserInviteMutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables - await Promise.all([ - queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)), - queryClient.invalidateQueries(authKeys.usersCount(projectRef)), - ]) + await Promise.all([queryClient.invalidateQueries(authKeys.usersInfinite(projectRef))]) await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/data/auth/users-count-query.ts b/apps/studio/data/auth/users-count-query.ts index 0f0c7371ec6f8..9c8730eff1013 100644 --- a/apps/studio/data/auth/users-count-query.ts +++ b/apps/studio/data/auth/users-count-query.ts @@ -1,7 +1,7 @@ +import { getUsersCountSQL } from '@supabase/pg-meta/src/sql/studio/get-users-count' import { useQuery, type UseQueryOptions } from '@tanstack/react-query' import { executeSql, type ExecuteSqlError } from 'data/sql/execute-sql-query' -import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from 'data/table-rows/table-rows-count-query' import { authKeys } from './keys' import { type Filter } from './users-infinite-query' @@ -14,86 +14,6 @@ type UsersCountVariables = { forceExactCount?: boolean } -const getUsersCountSql = ({ - filter, - keywords, - providers, - forceExactCount = false, -}: { - filter?: Filter - keywords?: string - providers?: string[] - forceExactCount?: boolean -}) => { - const hasValidKeywords = keywords && keywords !== '' - - const conditions: string[] = [] - const baseQueryCount = `select count(*) from auth.users` - const baseQuerySelect = `select * from auth.users` - - if (hasValidKeywords) { - // [Joshen] Escape single quotes properly - const formattedKeywords = keywords.replaceAll("'", "''") - conditions.push( - `id::text ilike '%${formattedKeywords}%' or email ilike '%${formattedKeywords}%' or phone ilike '%${formattedKeywords}%'` - ) - } - - if (filter === 'verified') { - conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`) - } else if (filter === 'anonymous') { - conditions.push(`is_anonymous is true`) - } else if (filter === 'unverified') { - conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`) - } - - if (providers && providers.length > 0) { - // [Joshen] This is arguarbly not fully optimized, but at the same time not commonly used - // JFYI in case we do eventually run into performance issues here when filtering for SAML provider - if (providers.includes('saml 2.0')) { - conditions.push( - `(select jsonb_agg(case when value ~ '^sso' then 'sso' else value end) from jsonb_array_elements_text((raw_app_meta_data ->> 'providers')::jsonb)) ?| array[${providers.map((p) => (p === 'saml 2.0' ? `'sso'` : `'${p}'`)).join(', ')}]`.trim() - ) - } else { - conditions.push( - `(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]` - ) - } - } - - const combinedConditions = conditions.map((x) => `(${x})`).join(' and ') - const whereClause = conditions.length > 0 ? ` where ${combinedConditions}` : '' - - if (forceExactCount) { - return `select (${baseQueryCount}${whereClause}), false as is_estimate;` - } else { - const selectBaseSql = `${baseQuerySelect}${whereClause}` - const countBaseSql = `${baseQueryCount}${whereClause}` - - const escapedSelectSql = selectBaseSql.replaceAll("'", "''") - - const sql = ` -${COUNT_ESTIMATE_SQL} - -with approximation as ( - select reltuples as estimate - from pg_class - where oid = 'auth.users'::regclass -) -select - case - when estimate = -1 then (select pg_temp.count_estimate('${escapedSelectSql}'))::int - when estimate > ${THRESHOLD_COUNT} then ${conditions.length > 0 ? `(select pg_temp.count_estimate('${escapedSelectSql}'))::int` : 'estimate::int'} - else (${countBaseSql}) - end as count, - estimate = -1 or estimate > ${THRESHOLD_COUNT} as is_estimate -from approximation; -`.trim() - - return sql - } -} - export async function getUsersCount( { projectRef, @@ -105,7 +25,7 @@ export async function getUsersCount( }: UsersCountVariables, signal?: AbortSignal ) { - const sql = getUsersCountSql({ filter, keywords, providers, forceExactCount }) + const sql = getUsersCountSQL({ filter, keywords, providers, forceExactCount }) const { result } = await executeSql( { @@ -133,6 +53,7 @@ export async function getUsersCount( export type UsersCountData = Awaited> export type UsersCountError = ExecuteSqlError +/** [Joshen] Be wary of using this as it could potentially cause a huge load on the user's DB */ export const useUsersCountQuery = ( { projectRef, diff --git a/apps/studio/data/auth/users-infinite-query.ts b/apps/studio/data/auth/users-infinite-query.ts index 8fbf6d50ba079..2fed18be6dec1 100644 --- a/apps/studio/data/auth/users-infinite-query.ts +++ b/apps/studio/data/auth/users-infinite-query.ts @@ -1,3 +1,4 @@ +import { getPaginatedUsersSQL } from '@supabase/pg-meta/src/sql/studio/get-users-paginated' import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' import type { components } from 'data/api' @@ -6,131 +7,37 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' import { authKeys } from './keys' -export type Filter = 'verified' | 'unverified' | 'anonymous' - -export type UsersVariables = { +const USERS_PAGE_LIMIT = 50 +type UsersData = { result: User[] } +type UsersError = ExecuteSqlError +type UsersVariables = { projectRef?: string connectionString?: string | null page?: number keywords?: string filter?: Filter providers?: string[] - sort?: 'created_at' | 'email' | 'phone' | 'last_sign_in_at' + sort?: 'id' | 'created_at' | 'email' | 'phone' | 'last_sign_in_at' order?: 'asc' | 'desc' -} -export const USERS_PAGE_LIMIT = 50 -export type User = components['schemas']['UserBody'] & { - providers: readonly string[] + column?: 'id' | 'email' | 'phone' + startAt?: string } -export const getUsersSQL = ({ - page = 0, - verified, - keywords, - providers, - sort, - order, -}: { - page: number - verified?: Filter - keywords?: string - providers?: string[] - sort: string - order: 'asc' | 'desc' -}) => { - const offset = page * USERS_PAGE_LIMIT - const hasValidKeywords = keywords && keywords !== '' - - const conditions: string[] = [] - - if (hasValidKeywords) { - // [Joshen] Escape single quotes properly - const formattedKeywords = keywords.replaceAll("'", "''") - conditions.push( - `id::text like '%${formattedKeywords}%' or email like '%${formattedKeywords}%' or phone like '%${formattedKeywords}%' or raw_user_meta_data->>'full_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'first_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'last_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'display_name' ilike '%${formattedKeywords}%'` - ) - } - - if (verified === 'verified') { - conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`) - } else if (verified === 'anonymous') { - conditions.push(`is_anonymous is true`) - } else if (verified === 'unverified') { - conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`) - } - - if (providers && providers.length > 0) { - // [Joshen] This is arguarbly not fully optimized, but at the same time not commonly used - // JFYI in case we do eventually run into performance issues here when filtering for SAML provider - if (providers.includes('saml 2.0')) { - conditions.push( - `(select jsonb_agg(case when value ~ '^sso' then 'sso' else value end) from jsonb_array_elements_text((raw_app_meta_data ->> 'providers')::jsonb)) ?| array[${providers.map((p) => (p === 'saml 2.0' ? `'sso'` : `'${p}'`)).join(', ')}]`.trim() - ) - } else { - conditions.push( - `(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]` - ) - } - } - - const combinedConditions = conditions.map((x) => `(${x})`).join(' and ') - const sortOn = sort ?? 'created_at' - const sortOrder = order ?? 'desc' - - const usersQuery = ` -with - users_data as ( - select - id, - email, - banned_until, - created_at, - confirmed_at, - confirmation_sent_at, - is_anonymous, - is_sso_user, - invited_at, - last_sign_in_at, - phone, - raw_app_meta_data, - raw_user_meta_data, - updated_at - from - auth.users - ${conditions.length > 0 ? ` where ${combinedConditions}` : ''} - order by - "${sortOn}" ${sortOrder} nulls last - limit - ${USERS_PAGE_LIMIT} - offset - ${offset} - ) -select - *, - coalesce( - ( - select - array_agg(distinct i.provider) - from - auth.identities i - where - i.user_id = users_data.id - ), - '{}'::text[] - ) as providers -from - users_data; - `.trim() - - return usersQuery -} - -export type UsersData = { result: User[] } -export type UsersError = ExecuteSqlError +export type Filter = 'verified' | 'unverified' | 'anonymous' +export type User = components['schemas']['UserBody'] & { providers: readonly string[] } export const useUsersInfiniteQuery = ( - { projectRef, connectionString, keywords, filter, providers, sort, order }: UsersVariables, + { + projectRef, + connectionString, + keywords, + filter, + providers, + sort, + order, + column, + }: UsersVariables, { enabled = true, ...options }: UseInfiniteQueryOptions = {} ) => { const { data: project } = useSelectedProjectQuery() @@ -143,13 +50,16 @@ export const useUsersInfiniteQuery = ( { projectRef, connectionString, - sql: getUsersSQL({ - page: pageParam, + sql: getPaginatedUsersSQL({ + page: column ? undefined : pageParam, verified: filter, keywords, providers, - sort: sort ?? 'created_at', - order: order ?? 'desc', + sort: sort ?? 'id', + order: order ?? 'asc', + limit: USERS_PAGE_LIMIT, + column, + startAt: column ? pageParam : undefined, }), queryKey: authKeys.usersInfinite(projectRef), }, @@ -159,10 +69,16 @@ export const useUsersInfiniteQuery = ( { enabled: enabled && typeof projectRef !== 'undefined' && isActive, getNextPageParam(lastPage, pages) { - const page = pages.length const hasNextPage = lastPage.result.length >= USERS_PAGE_LIMIT - if (!hasNextPage) return undefined - return page + if (column) { + const lastItem = lastPage.result[lastPage.result.length - 1] + if (hasNextPage && lastItem) return lastItem[column] + return undefined + } else { + const page = pages.length + if (!hasNextPage) return undefined + return page + } }, ...options, } diff --git a/apps/studio/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts index f6d57cb4a2ee6..fd888b8528c46 100644 --- a/apps/studio/data/table-rows/table-rows-count-query.ts +++ b/apps/studio/data/table-rows/table-rows-count-query.ts @@ -1,5 +1,10 @@ import { Query } from '@supabase/pg-meta/src/query' +import { + COUNT_ESTIMATE_SQL, + THRESHOLD_COUNT, +} from '@supabase/pg-meta/src/sql/studio/get-count-estimate' import { QueryClient, useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-query' + import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' import type { Filter, SupaTable } from 'components/grid/types' import { prefetchTableEditor } from 'data/table-editor/table-editor-query' @@ -15,20 +20,6 @@ type GetTableRowsCountArgs = { enforceExactCount?: boolean } -export const THRESHOLD_COUNT = 50000 -export const COUNT_ESTIMATE_SQL = /* SQL */ ` -CREATE OR REPLACE FUNCTION pg_temp.count_estimate( - query text -) RETURNS integer LANGUAGE plpgsql AS $$ -DECLARE - plan jsonb; -BEGIN - EXECUTE 'EXPLAIN (FORMAT JSON)' || query INTO plan; - RETURN plan->0->'Plan'->'Plan Rows'; -END; -$$; -`.trim() - export const getTableRowsCountSql = ({ table, filters = [], diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index 50cd45d16b2c0..43798337c5108 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -7,6 +7,7 @@ import { type UseQueryOptions, } from '@tanstack/react-query' +import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate' import { IS_PLATFORM } from 'common' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' import { Filter, Sort, SupaRow, SupaTable } from 'components/grid/types' @@ -19,7 +20,6 @@ import { import { isRoleImpersonationEnabled } from 'state/role-impersonation-state' import { ExecuteSqlError, executeSql } from '../sql/execute-sql-query' import { tableRowKeys } from './keys' -import { THRESHOLD_COUNT } from './table-rows-count-query' import { formatFilterValue } from './utils' export interface GetTableRowsArgs { diff --git a/packages/pg-meta/src/sql/studio/get-count-estimate.ts b/packages/pg-meta/src/sql/studio/get-count-estimate.ts new file mode 100644 index 0000000000000..52c01e36d610f --- /dev/null +++ b/packages/pg-meta/src/sql/studio/get-count-estimate.ts @@ -0,0 +1,14 @@ +export const THRESHOLD_COUNT = 50000 + +export const COUNT_ESTIMATE_SQL = /* SQL */ ` +CREATE OR REPLACE FUNCTION pg_temp.count_estimate( + query text +) RETURNS integer LANGUAGE plpgsql AS $$ +DECLARE + plan jsonb; +BEGIN + EXECUTE 'EXPLAIN (FORMAT JSON)' || query INTO plan; + RETURN plan->0->'Plan'->'Plan Rows'; +END; +$$; +`.trim() diff --git a/packages/pg-meta/src/sql/studio/get-users-count.ts b/packages/pg-meta/src/sql/studio/get-users-count.ts new file mode 100644 index 0000000000000..1d1f5e6145d49 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/get-users-count.ts @@ -0,0 +1,79 @@ +import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from './get-count-estimate' + +export const USERS_COUNT_ESTIMATE_SQL = `select reltuples as estimate from pg_class where oid = 'auth.users'::regclass` + +export const getUsersCountSQL = ({ + filter, + keywords, + providers, + forceExactCount = false, +}: { + filter?: 'verified' | 'unverified' | 'anonymous' + keywords?: string + providers?: string[] + forceExactCount?: boolean +}) => { + const hasValidKeywords = keywords && keywords !== '' + + const conditions: string[] = [] + const baseQueryCount = `select count(*) from auth.users` + const baseQuerySelect = `select * from auth.users` + + if (hasValidKeywords) { + // [Joshen] Escape single quotes properly + const formattedKeywords = keywords.replaceAll("'", "''") + conditions.push( + `id::text ilike '%${formattedKeywords}%' or email ilike '%${formattedKeywords}%' or phone ilike '%${formattedKeywords}%'` + ) + } + + if (filter === 'verified') { + conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`) + } else if (filter === 'anonymous') { + conditions.push(`is_anonymous is true`) + } else if (filter === 'unverified') { + conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`) + } + + if (providers && providers.length > 0) { + // [Joshen] This is arguarbly not fully optimized, but at the same time not commonly used + // JFYI in case we do eventually run into performance issues here when filtering for SAML provider + if (providers.includes('saml 2.0')) { + conditions.push( + `(select jsonb_agg(case when value ~ '^sso' then 'sso' else value end) from jsonb_array_elements_text((raw_app_meta_data ->> 'providers')::jsonb)) ?| array[${providers.map((p) => (p === 'saml 2.0' ? `'sso'` : `'${p}'`)).join(', ')}]`.trim() + ) + } else { + conditions.push( + `(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]` + ) + } + } + + const combinedConditions = conditions.map((x) => `(${x})`).join(' and ') + const whereClause = conditions.length > 0 ? ` where ${combinedConditions}` : '' + + if (forceExactCount) { + return `select (${baseQueryCount}${whereClause}), false as is_estimate;` + } else { + const selectBaseSql = `${baseQuerySelect}${whereClause}` + const countBaseSql = `${baseQueryCount}${whereClause}` + + const escapedSelectSql = selectBaseSql.replaceAll("'", "''") + + const sql = ` +${COUNT_ESTIMATE_SQL} + +with approximation as (${USERS_COUNT_ESTIMATE_SQL}) +select + case + when estimate = -1 then (select pg_temp.count_estimate('${escapedSelectSql}'))::int + when estimate > ${THRESHOLD_COUNT} then ${conditions.length > 0 ? `(select pg_temp.count_estimate('${escapedSelectSql}'))::int` : 'estimate::int'} + else (${countBaseSql}) + end as count, + estimate = -1 or estimate > ${THRESHOLD_COUNT} as is_estimate +from approximation; +`.trim() + + return sql + } +} diff --git a/packages/pg-meta/src/sql/studio/get-users-paginated.ts b/packages/pg-meta/src/sql/studio/get-users-paginated.ts new file mode 100644 index 0000000000000..44ea342adc6cd --- /dev/null +++ b/packages/pg-meta/src/sql/studio/get-users-paginated.ts @@ -0,0 +1,182 @@ +interface getPaginatedUsersSQLProps { + page?: number + verified?: 'verified' | 'unverified' | 'anonymous' + keywords?: string + providers?: string[] + sort: string + order: 'asc' | 'desc' + limit?: number + + /** If set, uses fast queries but these don't allow any sorting so the above parameters are completely ignored. */ + column?: 'id' | 'email' | 'phone' + startAt?: string +} + +const DEFAULT_LIMIT = 50 + +function prefixToUUID(prefix: string, max: boolean) { + const mapped = '00000000-0000-0000-0000-000000000000' + .split('') + .map((c, i) => (c === '-' ? c : prefix[i] ?? c)) + + if (prefix.length >= mapped.length) { + return mapped.join('') + } + + if (prefix.length && prefix.length < 15) { + mapped[14] = '4' + } + + if (prefix.length && prefix.length < 20) { + mapped[19] = max ? 'b' : '8' + } + + if (max) { + for (let i = prefix.length; i < mapped.length; i += 1) { + if (mapped[i] === '0') { + mapped[i] = 'f' + } + } + } + + return mapped.join('') +} + +function stringRange(prefix: string) { + if (!prefix) { + return [prefix, undefined] + } + + const lastChar = prefix.charCodeAt(prefix.length - 1) + + if (lastChar >= `~`.charCodeAt(0)) { + // not ASCII + return [prefix, prefix] + } + + return [prefix, prefix.substring(0, prefix.length - 1) + String.fromCharCode(lastChar + 1)] +} + +export const getPaginatedUsersSQL = ({ + page = 0, + verified, + keywords, + providers, + sort, + order, + limit = DEFAULT_LIMIT, + + column, + startAt, +}: getPaginatedUsersSQLProps) => { + // IMPORTANT: DO NOT CHANGE THESE QUERIES EVEN IN THE SLIGHTEST WITHOUT CONSULTING WITH AUTH TEAM. + const offset = page * limit + const hasValidKeywords = keywords && keywords !== '' + + const conditions: string[] = [] + + if (hasValidKeywords) { + // [Joshen] Escape single quotes properly + const formattedKeywords = keywords.replaceAll("'", "''") + conditions.push( + `id::text like '%${formattedKeywords}%' or email like '%${formattedKeywords}%' or phone like '%${formattedKeywords}%' or raw_user_meta_data->>'full_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'first_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'last_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'display_name' ilike '%${formattedKeywords}%'` + ) + } + + if (verified === 'verified') { + conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`) + } else if (verified === 'anonymous') { + conditions.push(`is_anonymous is true`) + } else if (verified === 'unverified') { + conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`) + } + + if (providers && providers.length > 0) { + // [Joshen] This is arguarbly not fully optimized, but at the same time not commonly used + // JFYI in case we do eventually run into performance issues here when filtering for SAML provider + if (providers.includes('saml 2.0')) { + conditions.push( + `(select jsonb_agg(case when value ~ '^sso' then 'sso' else value end) from jsonb_array_elements_text((raw_app_meta_data ->> 'providers')::jsonb)) ?| array[${providers.map((p) => (p === 'saml 2.0' ? `'sso'` : `'${p}'`)).join(', ')}]`.trim() + ) + } else { + conditions.push( + `(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]` + ) + } + } + + const combinedConditions = conditions.map((x) => `(${x})`).join(' and ') + const sortOn = sort ?? 'created_at' + const sortOrder = order ?? 'desc' + + let actualQuery = `${conditions.length > 0 ? ` where ${combinedConditions}` : ''} + order by + "${sortOn}" ${sortOrder} nulls last + limit + ${limit} + offset + ${offset} + ` + + // DON'T TOUCH THESE QUERIES. ONE CHARACTER OFF AND DISASTER. + let firstOperator = startAt ? '>' : '>=' + + if (column === 'email') { + const range = stringRange(keywords ?? '') + + actualQuery = `where lower(email) ${firstOperator} '${startAt ? startAt : range[0]}' ${range[1] ? `and lower(email) < '${range[1]}'` : ''} and instance_id = '00000000-0000-0000-0000-000000000000'::uuid order by instance_id, lower(email) asc limit ${limit}` + } else if (column === 'phone') { + const range = stringRange(keywords ?? '') + + actualQuery = `where phone ${firstOperator} '${startAt ? startAt : range[0]}' ${range[1] ? `and phone < '${range[1]}'` : ''} order by phone asc limit ${limit}` + } else if (column === 'id') { + const isMatchingUUIDValue = prefixToUUID(keywords ?? '', false) === keywords + if (isMatchingUUIDValue) { + actualQuery = `where id = '${keywords}' order by id asc limit ${limit}` + } else { + actualQuery = `where id ${firstOperator} '${startAt ? startAt : prefixToUUID(keywords ?? '', false)}' and id < '${prefixToUUID(keywords ?? '', true)}' order by id asc limit ${limit}` + } + } + + let usersData = ` + select + auth.users.id, + auth.users.email, + auth.users.banned_until, + auth.users.created_at, + auth.users.confirmed_at, + auth.users.confirmation_sent_at, + auth.users.is_anonymous, + auth.users.is_sso_user, + auth.users.invited_at, + auth.users.last_sign_in_at, + auth.users.phone, + auth.users.raw_app_meta_data, + auth.users.raw_user_meta_data, + auth.users.updated_at + from + auth.users + ${actualQuery}` + + let usersQuery = ` +with + users_data as (${usersData}) +select + *, + coalesce( + ( + select + array_agg(distinct i.provider) + from + auth.identities i + where + i.user_id = users_data.id + ), + '{}'::text[] + ) as providers +from + users_data; + `.trim() + + return usersQuery +}