diff --git a/e2e/admin-rbac.spec.ts b/e2e/admin-rbac.spec.ts index 853c1b4b6f..4e1ca3da3d 100644 --- a/e2e/admin-rbac.spec.ts +++ b/e2e/admin-rbac.spec.ts @@ -86,18 +86,45 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn ), ).toBe(true) - const initialUsersApiResponse = await page.request.get( - '/admin/users.json?pageSize=100', - ) - expect(initialUsersApiResponse.ok()).toBe(true) - const initialUsersPayload = await initialUsersApiResponse.json() - expect(initialUsersPayload.ok).toBe(true) - const lastUsersPage = Math.max( - 1, - Math.ceil(Number(initialUsersPayload.total) / 100), + // Server-side search: typing in the accounts search filters the list, + // writes `q` to the URL, and the reported total shrinks to match. + const usersSearchInput = page.getByLabel('Search', { exact: true }) + await usersSearchInput.fill(memberUser.username) + await expect(page).toHaveURL(new RegExp(`q=${memberUser.username}`)) + await expect( + page.getByRole('button', { name: memberUser.username }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: adminUser.username }), + ).toHaveCount(0) + const searchApiResponse = await page.request.get( + `/admin/users.json?q=${memberUser.username}`, ) + expect(searchApiResponse.ok()).toBe(true) + const searchPayload = await searchApiResponse.json() + expect(searchPayload.total).toBe(1) + expect(searchPayload.users[0].email).toBe(memberUser.email) + + await usersSearchInput.fill(`no-user-matches-${runId}`) + await expect( + page.getByText('No users match the current filters.'), + ).toBeVisible() - await page.goto(`/admin/users?pageSize=100&page=${lastUsersPage}`) + // Infinite scroll: with a one-user page size the sentinel is visible in + // the under-filled list and auto-loads following pages. + await page.goto('/admin/users?pageSize=1') + await expect(page.getByRole('heading', { name: 'Admin users' })).toBeVisible() + await expect(page.getByText(/Showing ([2-9]|\d{2,}) of \d+/)).toBeVisible() + + // Deep links with `?page=N` must not anchor the list past unreachable + // earlier pages: the initial window always seeds from page one. + await page.goto('/admin/users?pageSize=1&page=99999') + await expect(page.getByRole('heading', { name: 'Admin users' })).toBeVisible() + await expect(page.getByText(/Showing [1-9]\d* of \d+/)).toBeVisible() + + // Both rbac users share the `rbac-${runId}` username suffix, so search + // pins the list to exactly the accounts this test seeded. + await page.goto(`/admin/users?q=rbac-${runId}`) await expect(page.getByRole('heading', { name: 'Admin users' })).toBeVisible() // The first user is auto-selected, so the email can render in both the // list and the detail panel — assert on the list entry specifically. @@ -109,7 +136,7 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn await expect(page.getByText('super-secret-value')).toHaveCount(0) const usersApiResponse = await page.request.get( - `/admin/users.json?pageSize=100&page=${lastUsersPage}`, + `/admin/users.json?q=rbac-${runId}`, ) expect(usersApiResponse.ok()).toBe(true) const usersPayload = await usersApiResponse.json() @@ -124,7 +151,7 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn // The usage drill-down lives on the users page and loads for the // selected account only. - await page.goto(`/admin/users?pageSize=100&page=${lastUsersPage}`) + await page.goto(`/admin/users?q=rbac-${runId}`) await page.getByRole('button', { name: memberUser.username }).click() await expect(page.getByText('Usage & quotas')).toBeVisible() await expect( @@ -164,7 +191,7 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn expect(JSON.stringify(insightsPayload)).not.toContain('super-secret-value') expect(JSON.stringify(insightsPayload)).not.toContain(memberUser.email) - await page.goto(`/admin/users?pageSize=100&page=${lastUsersPage}`) + await page.goto(`/admin/users?q=rbac-${runId}`) await page.getByRole('button', { name: memberUser.username }).click() await expect(page.getByText('Account metadata only')).toBeVisible() @@ -174,7 +201,7 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn await page.getByRole('button', { name: 'Save plan' }).click() await expect(planSelect).toHaveValue('pro') const planApiResponse = await page.request.get( - `/admin/users.json?pageSize=100&page=${lastUsersPage}`, + `/admin/users.json?q=rbac-${runId}`, ) expect(planApiResponse.ok()).toBe(true) const planPayload = await planApiResponse.json() @@ -183,7 +210,22 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn ) expect(memberAfterPlan.plan).toBe('pro') - const roleSelect = page.getByLabel('Role') + // Mutations under an active role filter: removing the filtered role must + // drop the row from the list and shrink the filtered total in place. + await page.goto(`/admin/users?q=rbac-${runId}&role=user`) + await expect(page.getByText('Showing 2 of 2 accounts')).toBeVisible() + await page.getByRole('button', { name: memberUser.username }).click() + const filteredRoleSelect = page.getByLabel('Role', { exact: true }) + await filteredRoleSelect.selectOption('user') + await page.getByRole('button', { name: 'Remove', exact: true }).click() + await expect( + page.getByRole('button', { name: memberUser.username }), + ).toHaveCount(0) + await expect(page.getByText('Showing 1 of 1 account')).toBeVisible() + + await page.goto(`/admin/users?q=rbac-${runId}`) + await page.getByRole('button', { name: memberUser.username }).click() + const roleSelect = page.getByLabel('Role', { exact: true }) await roleSelect.selectOption('admin') await page.getByRole('button', { name: 'Assign', exact: true }).click() await page.context().clearCookies() diff --git a/packages/worker/client/infinite-list.node.test.ts b/packages/worker/client/infinite-list.node.test.ts new file mode 100644 index 0000000000..5b2fc5a034 --- /dev/null +++ b/packages/worker/client/infinite-list.node.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from 'vitest' +import { + createInfiniteList, + type InfiniteListSnapshot, +} from './infinite-list.ts' + +type Item = { id: number } + +function createList() { + let snapshot: InfiniteListSnapshot | null = null + const list = createInfiniteList({ + mergeDirection: 'append', + getKey: (item) => String(item.id), + onSnapshot: (nextSnapshot) => { + snapshot = nextSnapshot + }, + }) + return { list, getSnapshot: () => snapshot ?? list.getSnapshot() } +} + +test('loadMore appends, dedupes overlap, and tracks hasMore', async () => { + const { list, getSnapshot } = createList() + + await list.loadInitial(async () => ({ + items: [{ id: 1 }, { id: 2 }], + hasMore: true, + totalCount: 3, + })) + expect(getSnapshot().items.map((item) => item.id)).toEqual([1, 2]) + expect(getSnapshot().hasMore).toBe(true) + + // Overlapping windows (an item created while scrolling shifts offsets) + // must not produce duplicate rows. + const loaded = await list.loadMore(async () => ({ + items: [{ id: 2 }, { id: 3 }], + hasMore: false, + totalCount: 3, + })) + expect(loaded).toBe(true) + expect(getSnapshot().items.map((item) => item.id)).toEqual([1, 2, 3]) + expect(getSnapshot().hasMore).toBe(false) + + // Nothing more to load: loadMore is a no-op that reports false. + expect( + await list.loadMore(async () => ({ + items: [{ id: 4 }], + hasMore: false, + totalCount: 4, + })), + ).toBe(false) + expect(getSnapshot().items.map((item) => item.id)).toEqual([1, 2, 3]) +}) + +test('reset invalidates an in-flight loadMore so a stale page never lands', async () => { + const { list, getSnapshot } = createList() + await list.loadInitial(async () => ({ + items: [{ id: 1 }], + hasMore: true, + totalCount: 10, + })) + + let releaseStaleLoad = () => {} + const staleGate = new Promise((resolve) => { + releaseStaleLoad = resolve + }) + const staleLoadMore = list.loadMore(async () => { + await staleGate + return { items: [{ id: 2 }], hasMore: true, totalCount: 10 } + }) + + // Filters changed: the window is re-seeded while the old page is in + // flight (this is what the admin users page does on every filter edit). + list.reset() + list.replaceWindow({ + items: [{ id: 7 }], + hasMore: true, + totalCount: 2, + }) + releaseStaleLoad() + + expect(await staleLoadMore).toBe(false) + expect(getSnapshot().items.map((item) => item.id)).toEqual([7]) + expect(getSnapshot().totalCount).toBe(2) +}) + +test('loadMore failures surface an error without dropping loaded items', async () => { + const { list, getSnapshot } = createList() + await list.loadInitial(async () => ({ + items: [{ id: 1 }], + hasMore: true, + totalCount: 2, + })) + + const loaded = await list.loadMore(async () => { + throw new Error('Unable to load more users.') + }) + expect(loaded).toBe(false) + expect(getSnapshot().error).toBe('Unable to load more users.') + expect(getSnapshot().items.map((item) => item.id)).toEqual([1]) + expect(getSnapshot().isLoadingMore).toBe(false) +}) diff --git a/packages/worker/client/infinite-scroll.ts b/packages/worker/client/infinite-scroll.ts new file mode 100644 index 0000000000..038106a046 --- /dev/null +++ b/packages/worker/client/infinite-scroll.ts @@ -0,0 +1,39 @@ +import { ref } from 'remix/ui' + +/** + * Mixin for an infinite-scroll sentinel element rendered at the end of a + * list. When the sentinel scrolls into view (or near it), `loadMore` runs. + * `loadMore` must resolve to whether more items were actually loaded — + * `createInfiniteList().loadMore` already returns exactly that — so an + * under-filled viewport keeps loading until the sentinel leaves view or + * there is nothing left, without looping forever once the list is done. + */ +export function infiniteScrollSentinel( + loadMore: () => Promise | boolean, +) { + return ref((node: Element, signal: AbortSignal) => { + let running = false + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + if (running) return + running = true + void (async () => { + try { + const loadedMore = await loadMore() + if (signal.aborted || !loadedMore) return + // Re-observing delivers a fresh initial notification, so a + // still-visible sentinel triggers the next page load. + observer.unobserve(node) + observer.observe(node) + } finally { + running = false + } + })() + }, + { rootMargin: '200px' }, + ) + observer.observe(node) + signal.addEventListener('abort', () => observer.disconnect()) + }) +} diff --git a/packages/worker/client/replace-location.ts b/packages/worker/client/replace-location.ts new file mode 100644 index 0000000000..55a521e390 --- /dev/null +++ b/packages/worker/client/replace-location.ts @@ -0,0 +1,17 @@ +import { routerEvents } from '#client/client-router.tsx' + +/** + * Replace the current URL without adding a history entry and notify the + * router so route components re-render against the new location. Use this + * for URL-backed filter state (search fields, filter selects) where every + * keystroke would otherwise pollute history or restart scroll positions. + */ +export function replaceLocation(to: string) { + if (typeof window === 'undefined') return + const destination = new URL(to, window.location.href) + const nextPath = `${destination.pathname}${destination.search}${destination.hash}` + const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}` + if (nextPath === currentPath) return + window.history.replaceState({}, '', nextPath) + routerEvents.dispatchEvent(new Event('navigate')) +} diff --git a/packages/worker/client/routes/account-management-components.tsx b/packages/worker/client/routes/account-management-components.tsx index 484682285f..3d6f3a8468 100644 --- a/packages/worker/client/routes/account-management-components.tsx +++ b/packages/worker/client/routes/account-management-components.tsx @@ -11,8 +11,10 @@ import { cardCss, cardTitleCss, descriptionCss, + fieldCss, fieldLabelCss, getSecondaryButtonCss, + inputCss, layoutMaxWidths, } from '#client/styles/style-primitives.ts' @@ -339,6 +341,45 @@ export function AccountManagementList( ) } +type AccountManagementSearchFieldProps = { + label: string + placeholder: string + value: string + onInput: (value: string) => void +} + +/** + * URL-backed search input for sidebar lists (secrets, admin users, ...). + * Callers own the URL update — typically `replaceLocation(...)` with the + * value written to a `q` query param. + */ +export function AccountManagementSearchField( + handle: Handle, +) { + return () => ( + + ) +} + type AccountManagementListItemButtonProps = { active: boolean disabled?: boolean diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx index f147ab95af..70b5a42c6d 100644 --- a/packages/worker/client/routes/account-secrets.tsx +++ b/packages/worker/client/routes/account-secrets.tsx @@ -7,11 +7,8 @@ import { buildAccountSecretPath, parseAccountSecretPath, } from '@kody-internal/shared/account-secret-route.ts' -import { - navigate, - routerEvents, - readCurrentRouterHref, -} from '#client/client-router.tsx' +import { navigate, readCurrentRouterHref } from '#client/client-router.tsx' +import { replaceLocation } from '#client/replace-location.ts' import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx' import { consumeStaleNavigationData } from '#client/navigation-data.ts' import { @@ -57,6 +54,7 @@ import { AccountManagementList, AccountManagementListItemButton, AccountManagementMessage, + AccountManagementSearchField, AccountManagementShell, AccountManagementSidebar, AccountPageHeader, @@ -446,16 +444,6 @@ function buildBaseSecretsHref(search = '') { return buildSecretsHref(secretsBasePath, search) } -function replaceSecretsLocation(to: string) { - if (typeof window === 'undefined') return - const destination = new URL(to, window.location.href) - const nextPath = `${destination.pathname}${destination.search}${destination.hash}` - const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}` - if (nextPath === currentPath) return - window.history.replaceState({}, '', nextPath) - routerEvents.dispatchEvent(new Event('navigate')) -} - function getDataRefreshKey(href: string) { const url = new URL(href, 'http://localhost') const requestedHost = url.searchParams.get('allowed-host') ?? '' @@ -1312,33 +1300,16 @@ export function AccountSecretsRoute(handle: Handle) { gap: spacing.sm, })} > - + { + replaceLocation( + buildHrefWithUpdatedFilters({ search: value }), + ) + }} + /> + {status === 'ready' && users.length === 0 ? (

- No users found. + {hasActiveFilters + ? 'No users match the current filters.' + : 'No users found.'}

) : ( - - {users.map((user) => ( -
  • - { - if (isMutating) return - selectedUserId = user.id - message = null - handle.update() - }} - > - - {user.username} - - + + {users.map((user) => ( +
  • + { + if (isMutating) return + selectedUserId = user.id + message = null + handle.update() + }} > - {user.email} - - + {user.username} + + + {user.email} + + + {user.roles.length > 0 + ? user.roles.join(', ') + : 'No roles'} + + +
  • + ))} + {hasMore ? ( +
  • +
  • - ))} -
    + {isLoadingMore ? 'Loading more…' : 'Load more'} + + + ) : null} + + {usersSnapshot.error ? ( + + {usersSnapshot.error} + + ) : null} + {status === 'ready' ? ( +

    + Showing {users.length} of {totalCount}{' '} + {totalCount === 1 ? 'account' : 'accounts'} +

    + ) : null} + )} - {totalPages > 1 ? ( -
    - - - Page {page} of {totalPages} - - -
    - ) : null} } > diff --git a/packages/worker/src/app/admin-users-data.ts b/packages/worker/src/app/admin-users-data.ts index c9a1c76a60..6a0a15a81d 100644 --- a/packages/worker/src/app/admin-users-data.ts +++ b/packages/worker/src/app/admin-users-data.ts @@ -42,6 +42,49 @@ export type AdminUserListItem = Record & { const defaultPageSize = 20 const maxPageSize = 100 +type AdminUserListFilters = { + query: string + role: RoleName | null +} + +/** Read the `q` and `role` filter query params shared by the page and API. */ +function readAdminUserListFilters(url: URL): AdminUserListFilters { + const rawRole = url.searchParams.get('role')?.trim() ?? '' + return { + query: url.searchParams.get('q')?.trim() ?? '', + role: isRoleName(rawRole) ? rawRole : null, + } +} + +function escapeLikePattern(value: string) { + return value.replace(/[\\%_]/g, (char) => `\\${char}`) +} + +/** + * Build the WHERE clause shared by the page query and its COUNT so the + * reported total always matches the filtered result set. + */ +function buildAdminUserListWhereClause(filters: AdminUserListFilters) { + const conditions: Array = [] + const params: Array = [] + if (filters.query) { + const pattern = `%${escapeLikePattern(filters.query)}%` + conditions.push(`(username LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\')`) + params.push(pattern, pattern) + } + if (filters.role) { + conditions.push( + `id IN (SELECT ur.user_id FROM user_roles ur INNER JOIN roles r ON r.id = ur.role_id WHERE r.name = ?)`, + ) + params.push(filters.role) + } + return { + whereClause: + conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '', + params, + } +} + export async function loadAdminUsersData( env: Env, requestUrl: string, @@ -51,18 +94,21 @@ export async function loadAdminUsersData( defaultPageSize, maxPageSize, }) + const filters = readAdminUserListFilters(url) + const { whereClause, params } = buildAdminUserListWhereClause(filters) const [totalResult, userRows] = await Promise.all([ - env.APP_DB.prepare(`SELECT COUNT(*) AS total FROM users`).first<{ - total: number - }>(), + env.APP_DB.prepare(`SELECT COUNT(*) AS total FROM users ${whereClause}`) + .bind(...params) + .first<{ total: number }>(), env.APP_DB.prepare( `SELECT id, username, email, email_verified_at, plan, created_at, updated_at FROM users + ${whereClause} ORDER BY id ASC LIMIT ? OFFSET ?`, ) - .bind(pageSize, offset) + .bind(...params, pageSize, offset) .all(), ]) const total = totalResult?.total ?? 0 diff --git a/packages/worker/src/app/handlers/admin-users.node.test.ts b/packages/worker/src/app/handlers/admin-users.node.test.ts index 6615058dcc..915112c0cf 100644 --- a/packages/worker/src/app/handlers/admin-users.node.test.ts +++ b/packages/worker/src/app/handlers/admin-users.node.test.ts @@ -70,6 +70,37 @@ function createAdminTestEnv(input: { APP_DB: { prepare(query: string) { const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLowerCase() + // Mirrors buildAdminUserListWhereClause: an optional + // username/email LIKE pair followed by an optional role + // membership param, shared by the page query and its COUNT. + function applyListFilters(params: Array) { + let rows = Array.from(users.values()).sort((a, b) => a.id - b.id) + let paramIndex = 0 + if (normalizedQuery.includes('username like ?')) { + const pattern = String(params[paramIndex]) + paramIndex += 2 + const needle = pattern + .slice(1, -1) + .replace(/\\(.)/g, '$1') + .toLowerCase() + rows = rows.filter( + (row) => + row.username.toLowerCase().includes(needle) || + row.email.toLowerCase().includes(needle), + ) + } + if (normalizedQuery.includes('where r.name = ?')) { + const roleName = String(params[paramIndex]) + paramIndex += 1 + rows = rows.filter((row) => + userRoles.some( + (role) => + role.user_id === row.id && role.role_name === roleName, + ), + ) + } + return { rows, paramIndex } + } const execute = { async all() { if ( @@ -100,11 +131,10 @@ function createAdminTestEnv(input: { return { async all() { if (normalizedQuery.startsWith('select id, username, email')) { - const pageSize = Number(params[0]) - const offset = Number(params[1]) - const results = Array.from(users.values()) - .sort((a, b) => a.id - b.id) - .slice(offset, offset + pageSize) + const { rows, paramIndex } = applyListFilters(params) + const pageSize = Number(params[paramIndex]) + const offset = Number(params[paramIndex + 1]) + const results = rows.slice(offset, offset + pageSize) return { results: results as Array, meta: { changes: 0 } } } if (normalizedQuery.includes('where ur.user_id in')) { @@ -124,32 +154,40 @@ function createAdminTestEnv(input: { async first() { if ( normalizedQuery.includes( - 'select id, email from users where id =', + 'count(distinct ur.user_id) as count', ) ) { - const user = users.get(Number(params[0])) - return user ? ({ id: user.id, email: user.email } as T) : null + const roleName = String(params[0]) + const count = new Set( + userRoles + .filter((row) => row.role_name === roleName) + .map((row) => row.user_id), + ).size + return { count } as T + } + if ( + normalizedQuery.startsWith( + 'select count(*) as total from users', + ) + ) { + const { rows } = applyListFilters(params) + return { total: rows.length } as T } if ( normalizedQuery.includes( - 'select id, username, email, email_verified_at, plan, created_at, updated_at from users where id =', + 'select id, email from users where id =', ) ) { const user = users.get(Number(params[0])) - return user ? ({ ...user } as T) : null + return user ? ({ id: user.id, email: user.email } as T) : null } if ( normalizedQuery.includes( - 'count(distinct ur.user_id) as count', + 'select id, username, email, email_verified_at, plan, created_at, updated_at from users where id =', ) ) { - const roleName = String(params[0]) - const count = new Set( - userRoles - .filter((row) => row.role_name === roleName) - .map((row) => row.user_id), - ).size - return { count } as T + const user = users.get(Number(params[0])) + return user ? ({ ...user } as T) : null } return null }, @@ -299,6 +337,78 @@ test('admin users list payload exposes only account metadata fields', async () = ]) }) +test('admin users list applies q and role filters to the slice and total', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const env = createAdminTestEnv({ + users: [ + { + id: 1, + username: 'admin-user', + email: 'admin@example.com', + created_at: '2026-01-01 00:00:00', + updated_at: '2026-01-02 00:00:00', + }, + { + id: 2, + username: 'searchable-member', + email: 'member@example.com', + created_at: '2026-01-03 00:00:00', + updated_at: '2026-01-04 00:00:00', + }, + { + id: 3, + username: 'another-member', + email: 'searchable@example.com', + created_at: '2026-01-05 00:00:00', + updated_at: '2026-01-06 00:00:00', + }, + ], + userRoles: [ + { user_id: 1, role_name: 'admin' }, + { user_id: 2, role_name: 'user' }, + { user_id: 3, role_name: 'user' }, + ], + }) + const handler = createAdminUsersApiHandler(env as unknown as Env) + const listUsers = async (search: string) => { + const response = await handler.handler({ + request: new Request(`https://example.com/admin/users.json${search}`, { + headers: { Accept: 'application/json' }, + }), + params: {}, + url: new URL(`https://example.com/admin/users.json${search}`), + } as never) + expect(response.status).toBe(200) + return response.json() + } + + // q matches username or email; total reflects the filtered set. + const searchPayload = await listUsers('?q=searchable') + expect(searchPayload.total).toBe(2) + expect(searchPayload.users.map((user: { id: number }) => user.id)).toEqual([ + 2, 3, + ]) + + const rolePayload = await listUsers('?role=admin') + expect(rolePayload.total).toBe(1) + expect(rolePayload.users.map((user: { id: number }) => user.id)).toEqual([1]) + + const combinedPayload = await listUsers('?q=searchable&role=admin') + expect(combinedPayload.total).toBe(0) + expect(combinedPayload.users).toEqual([]) + + // Unknown role values are ignored rather than filtering everything out. + const unknownRolePayload = await listUsers('?role=not-a-role') + expect(unknownRolePayload.total).toBe(3) + + // Filters and pagination compose. + const pagedPayload = await listUsers('?q=searchable&pageSize=1&page=2') + expect(pagedPayload.total).toBe(2) + expect(pagedPayload.users.map((user: { id: number }) => user.id)).toEqual([3]) +}) + test('assign role action updates user roles and logs audit event', async () => { logAuditEventSpy.mockClear() mockModule.readAuthenticatedAppUser.mockResolvedValue( @@ -338,6 +448,14 @@ test('assign role action updates user roles and logs audit event', async () => { expect(response.status).toBe(200) const payload = await response.json() expect(payload.users[0].roles).toContain('admin') + // Mutations return the updated target so the client can patch it into + // an infinite-scroll window without resetting to the first page. + expect(payload.updatedUser).toEqual( + expect.objectContaining({ + id: 2, + roles: expect.arrayContaining(['admin', 'user']), + }), + ) expect(logAuditEventSpy).toHaveBeenCalledWith( expect.objectContaining({ category: 'admin', action: 'assign_role' }), ) diff --git a/packages/worker/src/app/handlers/admin-users.ts b/packages/worker/src/app/handlers/admin-users.ts index 9c4e24a930..ec9ed68ff7 100644 --- a/packages/worker/src/app/handlers/admin-users.ts +++ b/packages/worker/src/app/handlers/admin-users.ts @@ -4,6 +4,7 @@ import { type Action } from 'remix/router' import { getRequestIp, logAuditEvent } from '#app/audit-log.ts' import { loadAdminUserUsageData } from '#app/admin-user-usage-data.ts' import { + loadAdminUserByIdOrEmail, loadAdminUsersData, loadRolesByUserIds, adminUserListItemFieldNames, @@ -57,7 +58,12 @@ export function createAdminUsersHandler(env: Env) { return redirectToLogin(request) } - const adminUsers = await loadAdminUsersData(env, request.url) + // The HTML page always seeds the first window; infinite scroll owns + // later pages through the JSON API, so a stale `?page=N` link must + // not anchor the list past the rows it can never load. + const pageUrl = new URL(request.url) + pageUrl.searchParams.delete('page') + const adminUsers = await loadAdminUsersData(env, pageUrl.toString()) return renderAppPage({ request, @@ -208,8 +214,24 @@ async function handleAssignRoleAction(input: { reason: `target_user_id=${targetUserId};role=${roleName}`, }) - const payload = await loadAdminUsersData(input.env, input.request.url) - return jsonResponse(payload) + return buildMutationResponse(input.env, input.request.url, targetUserId) +} + +/** + * Mutation responses carry the refreshed page slice plus the updated target + * user, because with infinite scroll the target may live outside the first + * page and the client patches it in place instead of resetting the list. + */ +async function buildMutationResponse( + env: Env, + requestUrl: string, + targetUserId: number, +) { + const [payload, updatedUser] = await Promise.all([ + loadAdminUsersData(env, requestUrl), + loadAdminUserByIdOrEmail(env.APP_DB, { id: targetUserId }), + ]) + return jsonResponse({ ...payload, updatedUser }) } async function handleRemoveRoleAction(input: { @@ -294,8 +316,7 @@ async function handleRemoveRoleAction(input: { reason: `target_user_id=${targetUserId};role=${roleName}`, }) - const payload = await loadAdminUsersData(input.env, input.request.url) - return jsonResponse(payload) + return buildMutationResponse(input.env, input.request.url, targetUserId) } async function handleUpdatePlanAction(input: { @@ -340,8 +361,7 @@ async function handleUpdatePlanAction(input: { reason: `target_user_id=${targetUserId};plan=${planUpdate.plan ?? 'null'}`, }) - const payload = await loadAdminUsersData(input.env, input.request.url) - return jsonResponse(payload) + return buildMutationResponse(input.env, input.request.url, targetUserId) } /** diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index f10dac3d02..5b5ea2f58f 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -50,6 +50,15 @@ export type AdminUsersLoaderData = { availablePlans: Array } +/** + * POST (mutation) responses also carry the updated target user so the + * client can patch it into an infinite-scroll list that may have scrolled + * past the first page. + */ +export type AdminUsersMutationData = AdminUsersLoaderData & { + updatedUser: AdminUserListItem | null +} + export type AdminRoleListItem = { name: string description: string diff --git a/packages/worker/src/mcp/capabilities/admin/admin-user-list.ts b/packages/worker/src/mcp/capabilities/admin/admin-user-list.ts index 584cf809de..98edc66dba 100644 --- a/packages/worker/src/mcp/capabilities/admin/admin-user-list.ts +++ b/packages/worker/src/mcp/capabilities/admin/admin-user-list.ts @@ -6,6 +6,7 @@ import { adminCapabilityAccess, adminUserMetadataSchema, auditAdminCapabilityInvocation, + roleNameSchema, } from './admin-shared.ts' const inputSchema = z.object({ @@ -22,6 +23,13 @@ const inputSchema = z.object({ .max(100) .optional() .describe('Users per page. Defaults to 20 and maxes at 100.'), + query: z + .string() + .optional() + .describe('Case-insensitive substring match on username or email.'), + role: roleNameSchema + .optional() + .describe('Only return users holding this role (for example "admin").'), }) const outputSchema = z.object({ @@ -51,6 +59,8 @@ export const adminUserListCapability = defineDomainCapability( if (args.pageSize) { url.searchParams.set('pageSize', String(args.pageSize)) } + if (args.query) url.searchParams.set('q', args.query) + if (args.role) url.searchParams.set('role', args.role) const data = await loadAdminUsersData(ctx.env, url.toString()) return { total: data.total,