feat(settings): paginate the sessions and trusted-device lists (BUNYIP-177) #200

Merged
nrupard merged 3 commits from feat/BUNYIP-177-paginate-sessions-devices into main 2026-06-23 18:22:12 +02:00
Owner

What

Adds offset pagination to the Active Sessions and Trusted Devices lists on /settings, end to end (BUNYIP-177).

Why

/settings fetched and rendered every active session and trusted device with no limit (find_user_refresh_tokens / find_user_devices were unbounded SELECTs; the cards looped every row). An account that signs in from many devices without revoking accumulates non-expired tokens, so the page cost grew without bound - a latent DoS for high-activity accounts and an unbounded DOM.

How

Follows the existing PaginatedResponse / paginated() convention (page, per_page default 20 max 100, separate COUNT(*)).

  • Domain: new *_paginated + count_* repo fns (LIMIT/OFFSET + COUNT). The original unbounded fns stay - revoke-others still needs the full token set.
  • API: GET /v1/users/me/sessions and /trusted-devices take page/per_page and return a PaginatedResponse. Response shape changes from { sessions: [...] } / { devices: [...] } to the standard { items, total, page, per_page, total_pages } under the data envelope. Only bunyip-web consumes these.
  • Web: the api client returns PaginatedResponse<T>; /settings renders one page of each list with plain prev/next links (no htmx-pagination precedent exists, so this mirrors the admin pager). The two lists page independently - each pager preserves the other's page param (session_page / device_page). "Log out all other devices" now keys off total > 1.

Guard against breaking BUNYIP-176

GET /v1/users/me/sessions now returns data.items (was data.sessions), so the BUNYIP-176 sessions.spec assertion is updated to match in this same PR. profile.spec is unaffected (it checks the profile form, not the lists). CI will re-run both.

Verification

cargo check --workspace, cargo clippy --workspace --all-targets -- -D warnings, and cargo fmt --all --check all pass (via the rust-builder image); e2e tsc clean. No schema change (existing tables). The e2e suite runs on this PR.

🤖 Generated with Claude Code

## What Adds offset pagination to the **Active Sessions** and **Trusted Devices** lists on `/settings`, end to end (BUNYIP-177). ## Why `/settings` fetched and rendered every active session and trusted device with no limit (`find_user_refresh_tokens` / `find_user_devices` were unbounded `SELECT`s; the cards looped every row). An account that signs in from many devices without revoking accumulates non-expired tokens, so the page cost grew without bound - a latent DoS for high-activity accounts and an unbounded DOM. ## How Follows the existing `PaginatedResponse` / `paginated()` convention (`page`, `per_page` default 20 max 100, separate `COUNT(*)`). - **Domain**: new `*_paginated` + `count_*` repo fns (`LIMIT/OFFSET` + `COUNT`). The original unbounded fns stay - revoke-others still needs the full token set. - **API**: `GET /v1/users/me/sessions` and `/trusted-devices` take `page`/`per_page` and return a `PaginatedResponse`. **Response shape changes** from `{ sessions: [...] }` / `{ devices: [...] }` to the standard `{ items, total, page, per_page, total_pages }` under the data envelope. Only bunyip-web consumes these. - **Web**: the api client returns `PaginatedResponse<T>`; `/settings` renders one page of each list with plain prev/next links (no htmx-pagination precedent exists, so this mirrors the admin pager). The two lists page independently - each pager preserves the other's page param (`session_page` / `device_page`). "Log out all other devices" now keys off `total > 1`. ## Guard against breaking BUNYIP-176 `GET /v1/users/me/sessions` now returns `data.items` (was `data.sessions`), so the BUNYIP-176 `sessions.spec` assertion is updated to match in this same PR. `profile.spec` is unaffected (it checks the profile form, not the lists). CI will re-run both. ## Verification `cargo check --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo fmt --all --check` all pass (via the rust-builder image); `e2e` tsc clean. No schema change (existing tables). The e2e suite runs on this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(settings): paginate the sessions and trusted-device lists
Some checks failed
E2E / Playwright against deployment (pull_request) Failing after 46s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m24s
f6637ddc25
/settings fetched and rendered ALL of a user's active sessions and trusted devices with no limit (find_user_refresh_tokens / find_user_devices were unbounded SELECTs, and the cards looped every row). A user who signs in from many devices without revoking accumulates non-expired tokens, so the page cost grew without bound - a latent DoS for high-activity accounts and an unbounded DOM. This adds offset pagination end to end, following the existing PaginatedResponse / paginated() convention (page, per_page default 20 max 100, separate COUNT).

Domain (crates/bunyip-domain): add find_user_refresh_tokens_paginated + count_user_refresh_tokens and find_user_devices_paginated + count_user_devices (LIMIT/OFFSET + COUNT). The original unbounded fns are kept - revoke-others still needs the full token set.

API (bunyip-api): GET /v1/users/me/sessions and /trusted-devices take page/per_page and return a PaginatedResponse via paginated(). Response shape changes from { sessions: [...] } / { devices: [...] } to the standard { items, total, page, per_page, total_pages } under the data envelope. Only bunyip-web consumes these.

Web (bunyip-web): the api client returns PaginatedResponse<T>; /settings renders one page of each list with plain prev/next links (no htmx precedent exists; mirrors the admin pager). The two lists page independently - each pager preserves the other list's page param (session_page / device_page). "Log out all other devices" now keys off total > 1 so it shows even when the other sessions are on a later page.

e2e: GET /v1/users/me/sessions now returns data.items (was data.sessions), so the BUNYIP-176 sessions.spec assertion is updated to match. profile.spec is unaffected (it checks the profile form, not the lists).

#BUNYIP-177

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(settings): widen pagination offset to i64 + un-garble the pager doc
Some checks failed
E2E / Playwright against deployment (pull_request) Failing after 51s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m22s
96c1534724
Code-review follow-ups on the BUNYIP-177 pagination.

1. offset overflow: list_sessions / list_trusted_devices computed `offset = (page - 1) * per_page` as i32 with no upper bound on `page` (only `.max(1)`). A crafted `?page=2147483647&per_page=2` overflowed i32 - a negative OFFSET in release (Postgres rejects it -> 500) or a panic in debug. Compute the offset as i64 (per_page stays clamped to 1..=100) and widen the repo `offset` params to i64 so a large page just returns an empty page.

2. doc comment: the new settings_pager helper was inserted into the middle of sessions_card_body's two-part doc comment, so settings_pager's doc started with "Body of the Active Sessions card..." and sessions_card_body lost its summary. Restore each function's own doc.

clippy --all-targets -D warnings and fmt --check clean.

#BUNYIP-177

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merge origin/main into feat/BUNYIP-177-paginate-sessions-devices
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 49s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m22s
Create release / Create release from merged PR (pull_request) Has been skipped
bf49cbabb6
Resolve the sessions.spec conflict in favour of main's BUNYIP-183 accept-both
shape (data.items paginated OR data.sessions legacy): it survives the deploy
transition window where staging may still serve the pre-BUNYIP-177 shape, which
a data.items-only assertion would deadlock on.
nrupard deleted branch feat/BUNYIP-177-paginate-sessions-devices 2026-06-23 18:22:12 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!200
No description provided.