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
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()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Enter key fires on empty input

The onKeyDown handler fires handleSearch when Enter is pressed regardless of whether searchInput has content. This has the same side-effect as the empty button click: it resets usersOffset to 0 and sets searchQuery to '', making any previously visible results disappear. Adding a searchInput.trim() guard before calling handleSearch keeps keyboard and click behaviour consistent.

placeholder='Search by email or paste a user ID...'
/>
<Button variant='primary' onClick={handleSearch} disabled={usersLoading}>
{usersLoading ? 'Searching...' : 'Search'}
</Button>
Comment on lines +144 to 146
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Search button enabled on empty input

The Search button is currently enabled whenever usersLoading is false, even when searchInput is empty. Clicking Search with an empty (or whitespace-only) input will call handleSearch, reset usersOffset to 0, and set searchQuery to '' — causing any previously displayed results to immediately disappear (since the table is gated on searchQuery.length > 0). Adding || !searchInput.trim() to the disabled condition would prevent this accidental result-clearing and avoid unnecessary state updates.

Suggested change
<Button variant='primary' onClick={handleSearch} disabled={usersLoading}>
{usersLoading ? 'Searching...' : 'Search'}
</Button>
<Button variant='primary' onClick={handleSearch} disabled={usersLoading || !searchInput.trim()}>
{usersLoading ? 'Searching...' : 'Search'}

</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,
})
Comment on lines 77 to 83
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isLoading misses background re-fetches on the Search button

usersLoading maps to isLoading (isPending && isFetching). For subsequent searches against a new query key this is fine (no cached data → isPending is true). However, if the exact same searchQuery is submitted again after the 30-second staleTime window elapses, the query re-fetches in the background: isFetching is true but isPending is false, so isLoading stays false and the button is not disabled. Using isFetching instead provides a consistent disabled state during every in-flight request:

Suggested change
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,
})
} = useAdminUsers(usersOffset, PAGE_SIZE, searchQuery)

In admin-users.ts, expose isFetching alongside isLoading (or return the full query object), and in the component use it:

const {
  data: usersData,
  isLoading: usersLoading,
  isFetching: usersFetching,
  error: usersError,
} = useAdminUsers(usersOffset, PAGE_SIZE, searchQuery)

// ...

<Button variant='primary' onClick={handleSearch} disabled={usersFetching || !searchInput.trim()}>
  {usersFetching ? 'Searching...' : 'Search'}

Expand Down
Loading