Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { X } from 'lucide-react'
import { Search, X } from 'lucide-react'
import { SetStateAction } from 'react'

import {
Expand Down Expand Up @@ -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_
Expand Down Expand Up @@ -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'
)}
Expand Down
117 changes: 74 additions & 43 deletions apps/studio/components/interfaces/Auth/Users/UsersV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -87,24 +88,33 @@ export const UsersV2 = () => {
}
}, [showEmailPhoneColumns])

const [specificFilterColumn, setSpecificFilterColumn] = useState<
'id' | 'email' | 'phone' | 'freeform'
>('id' as const)

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 [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 [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,
Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -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,
}
Expand Down Expand Up @@ -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)}>
Expand Down Expand Up @@ -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: {
Expand All @@ -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
Expand Down Expand Up @@ -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>
Expand All @@ -597,7 +628,7 @@ export const UsersV2 = () => {
</ResizablePanelGroup>

<UsersFooter
filter={filter}
filter={filterUserType}
filterKeywords={filterKeywords}
selectedProviders={selectedProviders}
specificFilterColumn={specificFilterColumn}
Expand Down
4 changes: 3 additions & 1 deletion packages/common/constants/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 7 additions & 8 deletions packages/pg-meta/src/sql/studio/get-users-paginated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`
}
}

Expand All @@ -156,7 +155,7 @@ export const getPaginatedUsersSQL = ({
auth.users.updated_at
from
auth.users
${actualQuery}`
${whereStatement}`

let usersQuery = `
with
Expand Down
Loading