Chore/persist auth users search configuration (#39649)

* Perist auth users search configuration

* nit

* Clean up

* Swap more params to use URL state

* Clean up
This commit is contained in:
Joshen Lim authored and GitHub committed 2025-10-20 12:52:51 +08:00
1 parent 5005fc6081
commit 0ea4f24bb5
4 files changed
+88 -56

No files matched your search

@@ -1,4 +1,4 @@
import { X } from 'lucide-react'
import { Search, X } from 'lucide-react'
import { SetStateAction } from 'react'
import {
@@ -36,8 +36,8 @@ export const UsersSearch = ({
}: UsersSearchProps) => {
return (
<div className="flex items-center">
<div className="text-xs h-[26px] flex items-center px-2 border border-strong rounded-l-md bg-surface-300">
Search
<div className="text-xs h-[26px] flex items-center px-1.5 border border-strong rounded-l-md bg-surface-300">
<Search size={14} />
</div>
<Select_Shadcn_
@@ -83,7 +83,7 @@ export const UsersSearch = ({
<Input
size="tiny"
className={cn(
'w-64 bg-transparent rounded-l-none -ml-[1px]',
'w-[245px] bg-transparent rounded-l-none -ml-[1px]',
searchInvalid ? 'text-red-900 dark:border-red-900' : '',
search.length > 1 && 'pr-6'
)}
@@ -15,11 +15,13 @@ import { FormHeader } from 'components/ui/Forms/FormHeader'
import { authKeys } from 'data/auth/keys'
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers'
import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'
import {
Button,
cn,
@@ -51,7 +53,6 @@ import {
import { formatUserColumns, formatUsersData } from './Users.utils'
import { UsersFooter } from './UsersFooter'
import { UsersSearch } from './UsersSearch'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
export const UsersV2 = () => {
const queryClient = useQueryClient()
@@ -87,24 +88,33 @@ export const UsersV2 = () => {
}
}, [showEmailPhoneColumns])
const [specificFilterColumn, setSpecificFilterColumn] = useState<
'id' | 'email' | 'phone' | 'freeform'
>('id' as const)
const [specificFilterColumn, setSpecificFilterColumn] = useQueryState(
'filter',
parseAsStringEnum(['id', 'email', 'phone', 'freeform']).withDefault('id')
)
const [filterUserType, setFilterUserType] = useQueryState(
'userType',
parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all')
)
const [filterKeywords, setFilterKeywords] = useQueryState('keywords', { defaultValue: '' })
const [sortByValue, setSortByValue] = useQueryState('id:asc', { defaultValue: 'id:asc' })
const [sortColumn, sortOrder] = sortByValue.split(':')
const [selectedColumns, setSelectedColumns] = useQueryState(
'columns',
parseAsArrayOf(parseAsString, ',').withDefault([])
)
const [selectedProviders, setSelectedProviders] = useQueryState(
'providers',
parseAsArrayOf(parseAsString, ',').withDefault([])
)
const [columns, setColumns] = useState<Column<any>[]>([])
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [filterKeywords, setFilterKeywords] = useState('')
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
const [sortByValue, setSortByValue] = useState<string>('id:asc')
const [selectedUser, setSelectedUser] = useState<string>()
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
const [selectedUserToDelete, setSelectedUserToDelete] = useState<User>()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isDeletingUsers, setIsDeletingUsers] = useState(false)
const [showFreeformWarning, setShowFreeformWarning] = useState(false)
// [Joshen] Opting to store filter column, into local storage for now, which will initialize
// the page when landing on auth users page only if no query params for filter column provided
const [localStorageFilter, setLocalStorageFilter, { isSuccess: isLocalStorageFilterLoaded }] =
useLocalStorageQuery<'id' | 'email' | 'phone' | 'freeform'>(
LOCAL_STORAGE_KEYS.AUTH_USERS_FILTER(projectRef ?? ''),
'id'
)
const [
columnConfiguration,
@@ -115,7 +125,14 @@ export const UsersV2 = () => {
null as ColumnConfiguration[] | null
)
const [sortColumn, sortOrder] = sortByValue.split(':')
const [columns, setColumns] = useState<Column<any>[]>([])
const [search, setSearch] = useState('')
const [selectedUser, setSelectedUser] = useState<string>()
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
const [selectedUserToDelete, setSelectedUserToDelete] = useState<User>()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isDeletingUsers, setIsDeletingUsers] = useState(false)
const [showFreeformWarning, setShowFreeformWarning] = useState(false)
const {
data,
@@ -133,7 +150,10 @@ export const UsersV2 = () => {
projectRef,
connectionString: project?.connectionString,
keywords: filterKeywords,
filter: specificFilterColumn !== 'freeform' || filter === 'all' ? undefined : filter,
filter:
specificFilterColumn !== 'freeform' || filterUserType === 'all'
? undefined
: filterUserType,
providers: selectedProviders,
sort: sortColumn as 'id' | 'created_at' | 'email' | 'phone',
order: sortOrder as 'asc' | 'desc',
@@ -155,11 +175,18 @@ export const UsersV2 = () => {
// [Joshen] Only relevant for when selecting one user only
const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0])
const searchInvalid =
!search || specificFilterColumn === 'freeform' || specificFilterColumn === 'email'
? false
: specificFilterColumn === 'id'
? !search.match(UUIDV4_LEFT_PREFIX_REGEX)
: !search.match(PHONE_NUMBER_LEFT_PREFIX_REGEX)
const telemetryProps = {
sort_column: sortColumn,
sort_order: sortOrder,
providers: selectedProviders,
user_type: filter === 'all' ? undefined : filter,
user_type: filterUserType === 'all' ? undefined : filterUserType,
keywords: filterKeywords,
filter_column: specificFilterColumn === 'freeform' ? undefined : specificFilterColumn,
}
@@ -274,18 +301,22 @@ export const UsersV2 = () => {
specificFilterColumn,
])
const searchInvalid =
!search || specificFilterColumn === 'freeform' || specificFilterColumn === 'email'
? false
: specificFilterColumn === 'id'
? !search.match(UUIDV4_LEFT_PREFIX_REGEX)
: !search.match(PHONE_NUMBER_LEFT_PREFIX_REGEX)
// [Joshen] Load URL state for filter column only once, if no filter column found in URL params
useEffect(() => {
if (specificFilterColumn === 'id' && localStorageFilter !== 'id') {
setSpecificFilterColumn(localStorageFilter)
}
}, [])
useEffect(() => {
setLocalStorageFilter(specificFilterColumn)
}, [specificFilterColumn])
return (
<>
<div className="h-full flex flex-col">
<FormHeader className="py-4 px-6 !mb-0" title="Users" />
<div className="bg-surface-200 py-3 px-4 md:px-6 flex flex-col lg:flex-row lg:items-center justify-between gap-2 border-t">
<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 border-t">
{selectedUsers.size > 0 ? (
<div className="flex items-center gap-x-2">
<Button type="default" icon={<Trash />} onClick={() => setShowDeleteModal(true)}>
@@ -331,9 +362,9 @@ export const UsersV2 = () => {
{showUserTypeFilter && specificFilterColumn === 'freeform' && (
<Select_Shadcn_
value={filter}
value={filterUserType}
onValueChange={(val) => {
setFilter(val as Filter)
setFilterUserType(val as Filter)
sendEvent({
action: 'auth_users_search_submitted',
properties: {
@@ -345,16 +376,16 @@ export const UsersV2 = () => {
})
}}
>
<SelectTrigger_Shadcn_
size="tiny"
className={cn(
'w-[140px] !bg-transparent',
filterUserType === 'all' && 'border-dashed'
)}
>
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectTrigger_Shadcn_
size="tiny"
className={cn(
'w-[140px] !bg-transparent',
filter === 'all' && 'border-dashed'
)}
>
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="all" className="text-xs">
All users
@@ -572,12 +603,12 @@ export const UsersV2 = () => {
<Users className="text-foreground-lighter" strokeWidth={1} />
<div className="text-center">
<p className="text-foreground">
{filter !== 'all' || filterKeywords.length > 0
{filterUserType !== 'all' || filterKeywords.length > 0
? 'No users found'
: 'No users in your project'}
</p>
<p className="text-foreground-light">
{filter !== 'all' || filterKeywords.length > 0
{filterUserType !== 'all' || filterKeywords.length > 0
? 'There are currently no users based on the filters applied'
: 'There are currently no users who signed up to your project'}
</p>
@@ -597,7 +628,7 @@ export const UsersV2 = () => {
</ResizablePanelGroup>
<UsersFooter
filter={filter}
filter={filterUserType}
filterKeywords={filterKeywords}
selectedProviders={selectedProviders}
specificFilterColumn={specificFilterColumn}
+3 -1
View File
@@ -26,6 +26,9 @@ export const LOCAL_STORAGE_KEYS = {
DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`,
STORAGE_PREFERENCE: (ref: string) => `storage-explorer-${ref}`,
AUTH_USERS_FILTER: (ref: string) => `auth-users-filter-${ref}`,
AUTH_USERS_COLUMNS_CONFIGURATION: (ref: string) => `supabase-auth-users-columns-${ref}`,
SQL_EDITOR_INTELLISENSE: 'supabase_sql-editor-intellisense-enabled',
SQL_EDITOR_SPLIT_SIZE: 'supabase_sql-editor-split-size',
// Key to track which schemas are ok to be sent to AI. The project ref is intentionally put at the end for easier search in the browser console.
@@ -60,7 +63,6 @@ export const LOCAL_STORAGE_KEYS = {
FLY_POSTGRES_DEPRECATION_WARNING: 'fly-postgres-deprecation-warning-dismissed',
API_KEYS_FEEDBACK_DISMISSED: (ref: string) => `supabase-api-keys-feedback-dismissed-${ref}`,
MIDDLEWARE_OUTAGE_BANNER: 'middleware-outage-banner-2025-05-16',
AUTH_USERS_COLUMNS_CONFIGURATION: (ref: string) => `supabase-auth-users-columns-${ref}`,
REPORT_DATERANGE: 'supabase-report-daterange',
// api keys view switcher for new and legacy api keys
@@ -109,7 +109,7 @@ export const getPaginatedUsersSQL = ({
const sortOn = sort ?? 'created_at'
const sortOrder = order ?? 'desc'
let actualQuery = `${conditions.length > 0 ? ` where ${combinedConditions}` : ''}
let whereStatement = `${conditions.length > 0 ? ` where ${combinedConditions}` : ''}
order by
"${sortOn}" ${sortOrder} nulls last
limit
@@ -119,22 +119,21 @@ export const getPaginatedUsersSQL = ({
`
// DON'T TOUCH THESE QUERIES. ONE CHARACTER OFF AND DISASTER.
let firstOperator = startAt ? '>' : '>='
const 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}`
whereStatement = `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}`
whereStatement = `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}`
whereStatement = `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}`
whereStatement = `where id ${firstOperator} '${startAt ? startAt : prefixToUUID(keywords ?? '', false)}' and id < '${prefixToUUID(keywords ?? '', true)}' order by id asc limit ${limit}`
}
}
@@ -156,7 +155,7 @@ export const getPaginatedUsersSQL = ({
auth.users.updated_at
from
auth.users
${actualQuery}`
${whereStatement}`
let usersQuery = `
with