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>
This commit is contained in:
Waleed
2026-03-17 18:00:55 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 67478bbc80
commit 2bc11a70ba
2 changed files with 70 additions and 38 deletions
@@ -31,7 +31,8 @@ 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('')
@@ -39,8 +40,12 @@ export function Admin() {
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),
@@ -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)
@@ -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>
@@ -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>
@@ -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>
)}
+49 -20
View File
@@ -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 {
@@ -24,31 +25,59 @@ interface AdminUsersResponse {
total: number
}
async function fetchAdminUsers(offset: number, limit: number): Promise<AdminUsersResponse> {
const { data, error } = await client.admin.listUsers({
query: { limit, offset },
})
if (error) {
throw new Error(error.message ?? 'Failed to fetch users')
}
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 {
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,
})),
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,
searchField: 'email',
searchValue: searchQuery,
searchOperator: 'contains',
},
})
if (error) throw new Error(error.message ?? 'Failed to fetch users')
return {
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,
})