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
72 changes: 57 additions & 15 deletions e2e/admin-rbac.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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()

Expand All @@ -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()
Expand All @@ -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()
Expand Down
101 changes: 101 additions & 0 deletions packages/worker/client/infinite-list.node.test.ts
Original file line number Diff line number Diff line change
@@ -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<Item> | null = null
const list = createInfiniteList<Item>({
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<void>((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)
})
39 changes: 39 additions & 0 deletions packages/worker/client/infinite-scroll.ts
Original file line number Diff line number Diff line change
@@ -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> | 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())
})
}
17 changes: 17 additions & 0 deletions packages/worker/client/replace-location.ts
Original file line number Diff line number Diff line change
@@ -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'))
}
41 changes: 41 additions & 0 deletions packages/worker/client/routes/account-management-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
cardCss,
cardTitleCss,
descriptionCss,
fieldCss,
fieldLabelCss,
getSecondaryButtonCss,
inputCss,
layoutMaxWidths,
} from '#client/styles/style-primitives.ts'

Expand Down Expand Up @@ -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<AccountManagementSearchFieldProps>,
) {
return () => (
<label mix={css(fieldCss)}>
<span mix={css(fieldLabelCss)}>{handle.props.label}</span>
<input
type="search"
value={handle.props.value}
placeholder={handle.props.placeholder}
aria-label={handle.props.label}
mix={[
on('input', (event) =>
handle.props.onInput(
(event.currentTarget as HTMLInputElement).value,
),
),
css({
...inputCss,
paddingRight: spacing.xl,
}),
]}
/>
</label>
)
}

type AccountManagementListItemButtonProps = {
active: boolean
disabled?: boolean
Expand Down
Loading
Loading