mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user