Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
waleedlatif1/hangzhou v2 (#3647)
* feat(admin): add user search by email and ID, remove table border

- Replace Load Users button with a live search input; query fires on any input
- Email search uses listUsers with contains operator
- User ID search (UUID format) uses admin.getUser directly for exact lookup
- Remove outer border on user table that rendered white in dark mode
- Reset pagination to page 0 on new search

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(admin): replace live search with explicit search button

- Split searchInput (controlled input) from searchQuery (committed value)
  so the hook only fires on Search click or Enter, not every keystroke
- Gate table render on searchQuery.length > 0 to prevent stale results
  showing after input is cleared

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
  • Loading branch information
waleedlatif1 and claude authored Mar 18, 2026
commit 2bc11a70bac99376dedbad3e354f971f0a3a55b3
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,21 @@ export function Admin() {

const [workflowId, setWorkflowId] = useState('')
const [usersOffset, setUsersOffset] = useState(0)
const [usersEnabled, setUsersEnabled] = useState(false)
const [searchInput, setSearchInput] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [banUserId, setBanUserId] = useState<string | null>(null)
const [banReason, setBanReason] = useState('')

const {
data: usersData,
isLoading: usersLoading,
error: usersError,
refetch: refetchUsers,
} = useAdminUsers(usersOffset, PAGE_SIZE, usersEnabled)
} = useAdminUsers(usersOffset, PAGE_SIZE, searchQuery)

const handleSearch = () => {
setUsersOffset(0)
setSearchQuery(searchInput.trim())
}

const totalPages = useMemo(
() => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE),
Expand All @@ -62,14 +67,6 @@ export function Admin() {
)
}

const handleLoadUsers = () => {
if (usersEnabled) {
refetchUsers()
} else {
setUsersEnabled(true)
}
}

const pendingUserIds = useMemo(() => {
const ids = new Set<string>()
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
Expand Down Expand Up @@ -136,10 +133,16 @@ export function Admin() {
<div className='h-px bg-[var(--border-secondary)]' />

<div className='flex flex-col gap-[12px]'>
<div className='flex items-center justify-between'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>User Management</p>
<Button variant='active' onClick={handleLoadUsers} disabled={usersLoading}>
{usersLoading ? 'Loading...' : usersEnabled ? 'Refresh' : 'Load Users'}
<p className='font-medium text-[14px] text-[var(--text-primary)]'>User Management</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder='Search by email or paste a user ID...'
/>
<Button variant='primary' onClick={handleSearch} disabled={usersLoading}>
{usersLoading ? 'Searching...' : 'Search'}
</Button>
</div>

Expand All @@ -164,9 +167,9 @@ export function Admin() {
</div>
)}

{usersData && (
{searchQuery.length > 0 && usersData && (
<>
<div className='flex flex-col gap-[2px] rounded-[8px] border border-[var(--border-secondary)]'>
<div className='flex flex-col gap-[2px]'>
<div className='flex items-center gap-[12px] border-[var(--border-secondary)] border-b px-[12px] py-[8px] text-[12px] text-[var(--text-tertiary)]'>
<span className='w-[200px]'>Name</span>
<span className='flex-1'>Email</span>
Expand All @@ -176,7 +179,7 @@ export function Admin() {
</div>

{usersData.users.length === 0 && (
<div className='px-[12px] py-[16px] text-center text-[13px] text-[var(--text-tertiary)]'>
<div className='py-[16px] text-center text-[13px] text-[var(--text-tertiary)]'>
No users found.
</div>
)}
Expand Down
65 changes: 47 additions & 18 deletions apps/sim/hooks/queries/admin-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const logger = createLogger('AdminUsersQuery')
export const adminUserKeys = {
all: ['adminUsers'] as const,
lists: () => [...adminUserKeys.all, 'list'] as const,
list: (offset: number, limit: number) => [...adminUserKeys.lists(), offset, limit] as const,
list: (offset: number, limit: number, searchQuery: string) =>
[...adminUserKeys.lists(), offset, limit, searchQuery] as const,
}

interface AdminUser {
Expand All @@ -24,31 +25,59 @@ interface AdminUsersResponse {
total: number
}

async function fetchAdminUsers(offset: number, limit: number): Promise<AdminUsersResponse> {
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

function mapUser(u: {
id: string
name: string
email: string
role?: string | null
banned?: boolean | null
banReason?: string | null
}): AdminUser {
return {
id: u.id,
name: u.name || '',
email: u.email,
role: u.role ?? 'user',
banned: u.banned ?? false,
banReason: u.banReason ?? null,
}
}

async function fetchAdminUsers(
offset: number,
limit: number,
searchQuery: string
): Promise<AdminUsersResponse> {
if (UUID_REGEX.test(searchQuery.trim())) {
const { data, error } = await client.admin.getUser({ query: { id: searchQuery.trim() } })
if (error) throw new Error(error.message ?? 'Failed to fetch user')
if (!data) return { users: [], total: 0 }
return { users: [mapUser(data)], total: 1 }
}

const { data, error } = await client.admin.listUsers({
query: { limit, offset },
query: {
limit,
offset,
searchField: 'email',
searchValue: searchQuery,
searchOperator: 'contains',
},
})
if (error) {
throw new Error(error.message ?? 'Failed to fetch users')
}
if (error) throw new Error(error.message ?? 'Failed to fetch users')
return {
users: (data?.users ?? []).map((u) => ({
id: u.id,
name: u.name || '',
email: u.email,
role: u.role ?? 'user',
banned: u.banned ?? false,
banReason: u.banReason ?? null,
})),
users: (data?.users ?? []).map(mapUser),
total: data?.total ?? 0,
}
}

export function useAdminUsers(offset: number, limit: number, enabled: boolean) {
export function useAdminUsers(offset: number, limit: number, searchQuery: string) {
return useQuery({
queryKey: adminUserKeys.list(offset, limit),
queryFn: () => fetchAdminUsers(offset, limit),
enabled,
queryKey: adminUserKeys.list(offset, limit, searchQuery),
queryFn: () => fetchAdminUsers(offset, limit, searchQuery),
enabled: searchQuery.length > 0,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
Expand Down
Loading