feat: Added write_mode support to impersonation tokens with conditional…

- "backend/auth.py"
- "backend/models.py"
- "backend/routers/admin.py"
- "backend/tests/test_impersonation.py"

GSD-Task: S07/T01
This commit is contained in:
jlightner 2026-04-04 06:24:04 +00:00
parent f822415f6f
commit ab9dd2aa1b
14 changed files with 935 additions and 10 deletions

View file

@ -11,6 +11,6 @@ LightRAG becomes the primary search engine. Chat engine goes live (encyclopedic
| S03 | [B] Chat Engine MVP | high | S02 | ✅ | User asks a question, receives a streamed response with citations linking to source videos and technique pages |
| S04 | [B] Highlight Detection v1 | medium | — | ✅ | Scored highlight candidates generated from existing pipeline data for a sample of videos |
| S05 | [A] Audio Mode + Chapter Markers | medium | — | ✅ | Media player with waveform visualization in audio mode and chapter markers on the timeline |
| S06 | [A] Auto-Chapters Review UI | low | — | | Creator reviews detected chapters: drag boundaries, rename, reorder, approve for publication |
| S06 | [A] Auto-Chapters Review UI | low | — | | Creator reviews detected chapters: drag boundaries, rename, reorder, approve for publication |
| S07 | [A] Impersonation Polish + Write Mode | low | — | ⬜ | Impersonation write mode with confirmation modal. Audit log admin view shows all sessions. |
| S08 | Forgejo KB Update — Chat, Retrieval, Highlights | low | S01, S02, S03, S04, S05, S06, S07 | ⬜ | Forgejo wiki updated with chat engine, retrieval routing, and highlight detection docs |

View file

@ -0,0 +1,116 @@
---
id: S06
parent: M021
milestone: M021
provides:
- Creator chapter management API (4 endpoints)
- ChapterReview UI page at /creator/chapters/:videoId
- ChapterStatus enum and sort_order on KeyMoment model
- Sidebar navigation link to Chapters in CreatorDashboard
requires:
[]
affects:
- S08
key_files:
- backend/models.py
- backend/schemas.py
- alembic/versions/020_add_chapter_status_and_sort_order.py
- backend/routers/creator_chapters.py
- backend/routers/videos.py
- backend/main.py
- frontend/src/pages/ChapterReview.tsx
- frontend/src/pages/ChapterReview.module.css
- frontend/src/api/videos.ts
- frontend/src/App.tsx
- frontend/src/pages/CreatorDashboard.tsx
key_decisions:
- Public chapters endpoint falls back to all chapters when none are approved (backward compatibility)
- Status cycling: draft → approved → hidden → draft
- Region drag/resize persists via fire-and-forget updateChapter with error logging
- Video picker reuses consent API for creator video listing
- _verify_creator_owns_video helper pattern for ownership checks
patterns_established:
- Creator resource ownership verification via _verify_creator_owns_video helper
- Split route wrapper + detail component pattern for optional URL params
- WaveSurfer RegionsPlugin with drag:true/resize:true for interactive timeline editing
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M021/slices/S06/tasks/T01-SUMMARY.md
- .gsd/milestones/M021/slices/S06/tasks/T02-SUMMARY.md
- .gsd/milestones/M021/slices/S06/tasks/T03-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-04T06:13:44.794Z
blocker_discovered: false
---
# S06: [A] Auto-Chapters Review UI
**Built full creator chapter review UI: backend CRUD endpoints with auth-guarded chapter management, WaveSurfer waveform with draggable/resizable regions, inline editing, reorder, bulk approve, and routing with video picker.**
## What Happened
This slice delivered the complete chapter review workflow for creators across three tasks.
**T01 — Backend chapter management.** Added `ChapterStatus` enum (draft/approved/hidden) and `sort_order` column to the KeyMoment model with Alembic migration 020. Created 4 Pydantic schemas (ChapterUpdate, ChapterReorderItem, ChapterReorderRequest, ChapterBulkApproveRequest) and extended ChapterMarkerRead. Built `creator_chapters.py` router with 4 auth-guarded endpoints: GET chapters for a video, PATCH single chapter, PUT reorder, POST bulk-approve. Updated the public chapters endpoint to prefer approved chapters with fallback to all when none are approved (backward compatibility).
**T02 — Frontend chapter review page.** Extended the Chapter interface and videos.ts API module with 4 new functions (fetchCreatorChapters, updateChapter, reorderChapters, approveChapters). Created ChapterReview.tsx with WaveSurfer waveform (drag: true, resize: true on regions), region-updated event sync to API, inline title editing, up/down reorder arrows, status cycle button (draft→approved→hidden→draft), checkbox-based bulk approve, and loading/error/empty states. CSS module follows CreatorDashboard design patterns.
**T03 — Routing and navigation.** Added two protected lazy-loaded routes in App.tsx (/creator/chapters and /creator/chapters/:videoId). Replaced the disabled Content placeholder in CreatorDashboard sidebar with an active Chapters NavLink. Added a VideoPicker component that lists creator videos when no videoId param is present, using the consent API for video listing.
## Verification
All verification checks pass:
- `python -c "from models import KeyMoment, ChapterStatus; print(ChapterStatus.draft)"` → ChapterStatus.draft ✅
- `python -c "from schemas import ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest; print('schemas OK')"` → schemas OK ✅
- `grep -q 'creator_chapters' backend/main.py` → found ✅
- `test -f alembic/versions/020_add_chapter_status_and_sort_order.py` → exists ✅
- `cd frontend && npx tsc --noEmit` → 0 errors ✅
- `grep -q 'ChapterReview' frontend/src/App.tsx` → found ✅
- `grep -q '/creator/chapters' frontend/src/pages/CreatorDashboard.tsx` → found ✅
- `grep -q 'updateChapter' frontend/src/api/videos.ts` → found ✅
- `test -f frontend/src/pages/ChapterReview.tsx` → exists ✅
- `test -f frontend/src/pages/ChapterReview.module.css` → exists ✅
## Requirements Advanced
None.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Video picker uses consent API (fetchCreatorConsent) for video listing instead of a dedicated creator videos endpoint — pragmatic reuse of existing data. ChapterReview split into wrapper + ChapterReviewDetail subcomponent for cleaner param handling.
## Known Limitations
Migration 020 needs to be applied on ub01 (`docker exec chrysopedia-api alembic upgrade head`) before the new endpoints work. No drag-and-drop reorder — uses simple up/down arrows (sufficient for typical 5-15 chapter lists).
## Follow-ups
Run migration 020 on ub01 deployment. End-to-end test with real video chapters in the browser.
## Files Created/Modified
- `backend/models.py` — Added ChapterStatus enum, chapter_status and sort_order columns to KeyMoment
- `backend/schemas.py` — Added ChapterUpdate, ChapterReorderItem, ChapterReorderRequest, ChapterBulkApproveRequest schemas; extended ChapterMarkerRead
- `alembic/versions/020_add_chapter_status_and_sort_order.py` — Migration adding chapter_status enum type and sort_order column
- `backend/routers/creator_chapters.py` — New router with 4 auth-guarded chapter management endpoints
- `backend/routers/videos.py` — Updated public chapters endpoint to prefer approved chapters
- `backend/main.py` — Registered creator_chapters router
- `frontend/src/pages/ChapterReview.tsx` — Full chapter review page with waveform, inline editing, reorder, bulk approve, and video picker
- `frontend/src/pages/ChapterReview.module.css` — Styles for chapter review UI
- `frontend/src/api/videos.ts` — Added chapter_status/sort_order to Chapter interface; 4 new API functions
- `frontend/src/App.tsx` — Added /creator/chapters and /creator/chapters/:videoId routes
- `frontend/src/pages/CreatorDashboard.tsx` — Replaced disabled Content span with active Chapters NavLink

View file

@ -0,0 +1,75 @@
# S06: [A] Auto-Chapters Review UI — UAT
**Milestone:** M021
**Written:** 2026-04-04T06:13:44.794Z
## UAT: Auto-Chapters Review UI
### Preconditions
- Migration 020 applied (`docker exec chrysopedia-api alembic upgrade head`)
- At least one creator account exists with processed videos that have key moments (chapters)
- User is logged in as a creator
### Test 1: Sidebar Navigation
1. Navigate to `/creator/dashboard`
2. **Expected:** Sidebar shows "Chapters" link with document icon (not disabled "Content" placeholder)
3. Click "Chapters" link
4. **Expected:** Navigates to `/creator/chapters`
### Test 2: Video Picker (No videoId)
1. Navigate to `/creator/chapters` (no videoId)
2. **Expected:** Page shows list of creator's videos with titles
3. Click a video title
4. **Expected:** Navigates to `/creator/chapters/:videoId`
### Test 3: Chapter List Display
1. Navigate to `/creator/chapters/:videoId` for a video with chapters
2. **Expected:** Waveform renders with colored regions for each chapter. Chapter list below shows title, time range, status badge (draft/approved/hidden), reorder arrows, and checkbox
### Test 4: Waveform Region Drag
1. On the chapter review page, drag a region boundary on the waveform
2. **Expected:** Region resizes. After release, chapter's start/end time updates in the list below. API PATCH call fires to persist.
### Test 5: Inline Title Editing
1. Click the title text of a chapter in the list
2. Change the title text and blur/tab away
3. **Expected:** Title updates. API PATCH call persists the change.
### Test 6: Status Cycling
1. Click the status badge on a chapter row
2. **Expected:** Status cycles: draft → approved → hidden → draft. Badge color changes accordingly. API PATCH persists.
### Test 7: Reorder Chapters
1. Click the down arrow on the first chapter
2. **Expected:** Chapter moves to second position. All sort_order values update. API PUT /reorder fires.
3. Click the up arrow on the second chapter
4. **Expected:** Chapter moves back to first position.
### Test 8: Bulk Approve
1. Check boxes on 2+ draft chapters
2. Click "Approve Selected"
3. **Expected:** Selected chapters change to approved status. API POST /approve fires with selected IDs.
### Test 9: Approve All
1. Click "Approve All" button
2. **Expected:** All chapters become approved. API POST /approve fires with all chapter IDs.
### Test 10: Public Chapter Filtering
1. Approve some chapters for a video (not all)
2. In a different browser/incognito, view the video's public page
3. **Expected:** Only approved chapters display in the player timeline
4. Now hide all approved chapters (set all to draft/hidden)
5. **Expected:** Public page falls back to showing all chapters (backward compatibility)
### Test 11: Auth Guard
1. Log out and navigate directly to `/creator/chapters/:videoId`
2. **Expected:** Redirected to login page
### Test 12: Empty State
1. Navigate to `/creator/chapters/:videoId` for a video with no chapters
2. **Expected:** Page shows empty state message (no crash, no blank page)
### Edge Cases
- **Ownership check:** Attempt to access chapters for a video owned by a different creator → 403 Forbidden
- **Concurrent edits:** Drag a region while also editing title → both changes persist independently
- **Single chapter:** Reorder arrows disabled/hidden when only one chapter exists

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T03",
"unitId": "M021/S06/T03",
"timestamp": 1775283130846,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 9,
"verdict": "pass"
},
{
"command": "grep -q 'ChapterReview' src/App.tsx",
"exitCode": 2,
"durationMs": 9,
"verdict": "fail"
},
{
"command": "grep -q '/creator/chapters' src/pages/CreatorDashboard.tsx",
"exitCode": 2,
"durationMs": 10,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,114 @@
# S07: [A] Impersonation Polish + Write Mode
**Goal:** Add write mode toggle and audit log admin view to impersonation system
**Goal:** Impersonation write mode with confirmation modal. Audit log admin view shows all sessions.
**Demo:** After this: Impersonation write mode with confirmation modal. Audit log admin view shows all sessions.
## Tasks
- [x] **T01: Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint** — Add write_mode support to impersonation tokens and a paginated audit log endpoint.
## Steps
1. In `backend/models.py`, add `write_mode: Mapped[bool]` column to `ImpersonationLog` with `default=False, server_default=text('false')`. Import `text` from sqlalchemy if needed.
2. In `backend/auth.py`:
- Modify `create_impersonation_token()` to accept an optional `write_mode: bool = False` param. When True, add `"write_mode": True` to the JWT payload.
- Modify `get_current_user()` to also attach `_impersonation_write_mode` from the decoded payload: `user._impersonation_write_mode = payload.get("write_mode", False)`.
- Modify `reject_impersonation()` to check `_impersonation_write_mode` — if True, allow the request through (return user). Only block when impersonating WITHOUT write mode.
3. In `backend/routers/admin.py`:
- Add a Pydantic request body model `StartImpersonationRequest` with `write_mode: bool = False`.
- Modify `start_impersonation()` to accept this body, pass `write_mode` to `create_impersonation_token()`, and log `write_mode` in the `ImpersonationLog` row.
- Add response schema `ImpersonationLogItem(BaseModel)` with fields: `id`, `admin_name`, `target_name`, `action`, `write_mode`, `ip_address`, `created_at`.
- Add `GET /impersonation-log` endpoint (admin-only) that queries `ImpersonationLog` joined with `User` (aliased for admin and target) for display names. Support `?page=1&page_size=50` query params. Return `list[ImpersonationLogItem]`.
4. In `backend/tests/test_impersonation.py` (new file), write integration tests:
- Test: start impersonation without write_mode → write to `PUT /auth/me` is 403.
- Test: start impersonation with write_mode=true → write to `PUT /auth/me` succeeds (or at least is not 403 from reject_impersonation — may get validation error, that's fine).
- Test: `GET /admin/impersonation-log` returns log entries with admin/target names.
- Test: non-admin cannot access impersonation-log.
## Must-Haves
- [ ] `reject_impersonation` allows writes when token has `write_mode=true`
- [ ] `reject_impersonation` still blocks writes when `write_mode` absent or false
- [ ] `ImpersonationLog` records `write_mode` boolean
- [ ] `GET /admin/impersonation-log` returns paginated entries with display names
- [ ] All new tests pass
- Estimate: 1h
- Files: backend/auth.py, backend/models.py, backend/routers/admin.py, backend/tests/test_impersonation.py
- Verify: cd backend && python -m pytest tests/test_impersonation.py -v
- [ ] **T02: Frontend: confirmation modal, write-mode banner state, API write_mode param** — Add a confirmation modal for write-mode impersonation and update the banner to reflect mode.
## Steps
1. Create `frontend/src/components/ConfirmModal.tsx` — a reusable overlay modal:
- Props: `open: boolean`, `title: string`, `message: string`, `confirmLabel?: string`, `cancelLabel?: string`, `onConfirm: () => void`, `onCancel: () => void`, `variant?: 'warning' | 'danger'`.
- Renders a backdrop + centered card with title, message, cancel button, confirm button.
- Confirm button uses red/amber depending on variant.
- Closes on Escape key and backdrop click.
2. Create `frontend/src/components/ConfirmModal.module.css` — modal styling matching the dark theme.
3. Update `frontend/src/api/auth.ts`:
- Modify `impersonateUser()` to accept optional `writeMode?: boolean` param. When true, send `{ write_mode: true }` as JSON body with `Content-Type: application/json`.
- Add `ImpersonationLogEntry` interface: `{ id, admin_name, target_name, action, write_mode, ip_address, created_at }`.
- Add `fetchImpersonationLog(token: string, page?: number): Promise<ImpersonationLogEntry[]>` function calling `GET /admin/impersonation-log?page=N`.
4. Update `frontend/src/context/AuthContext.tsx`:
- Add `isWriteMode: boolean` to context value.
- Track write mode in state. When `startImpersonation` is called, accept optional `writeMode` param and pass to API.
- Expose `isWriteMode` in context.
5. Update `frontend/src/pages/AdminUsers.tsx`:
- Replace the single "View As" button with two buttons: "View As" (read-only) and "Edit As" (write mode).
- "Edit As" opens the ConfirmModal with a warning: "You are about to edit as {name}. Changes will be attributed to this creator and logged. Continue?"
- On confirm, call `startImpersonation(userId, true)`.
- "View As" works as before (no modal, read-only mode).
6. Update `frontend/src/components/ImpersonationBanner.tsx`:
- Pull `isWriteMode` from auth context.
- When write mode: banner background red (#dc2626), icon ✏️, text "Editing as {name}".
- When read mode: keep current amber (#b45309), icon 👁, text "Viewing as {name}".
- Add `body.impersonating-write` class in write mode for potential downstream styling.
## Must-Haves
- [ ] ConfirmModal component renders with backdrop, closes on Escape/backdrop click
- [ ] "Edit As" button shows confirmation modal before starting write-mode impersonation
- [ ] Banner shows red "Editing as" in write mode, amber "Viewing as" in read mode
- [ ] `impersonateUser` API function sends write_mode in request body
- [ ] `npm run build` passes with zero errors
- Estimate: 1h
- Files: frontend/src/components/ConfirmModal.tsx, frontend/src/components/ConfirmModal.module.css, frontend/src/api/auth.ts, frontend/src/context/AuthContext.tsx, frontend/src/pages/AdminUsers.tsx, frontend/src/components/ImpersonationBanner.tsx, frontend/src/components/ImpersonationBanner.module.css
- Verify: cd frontend && npm run build 2>&1 | tail -5
- [ ] **T03: Frontend: audit log admin page, route, and nav link** — Add an admin page displaying paginated impersonation audit log entries.
## Steps
1. Create `frontend/src/pages/AdminAuditLog.tsx`:
- Use `useDocumentTitle('Audit Log — Admin')` like other admin pages.
- Fetch impersonation log via `fetchImpersonationLog(token)` from `api/auth.ts` (added in T02).
- Render a table with columns: Date/Time, Admin, Target User, Action (start/stop), Write Mode (yes/no badge), IP Address.
- Format `created_at` as locale datetime string.
- Show loading and error states matching existing admin page patterns (see AdminUsers.tsx).
- Add simple pagination: "Previous" / "Next" buttons, tracking current page in state.
2. Create `frontend/src/pages/AdminAuditLog.module.css` — table styling matching AdminUsers.module.css patterns.
3. Update `frontend/src/App.tsx`:
- Add lazy import: `const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));`
- Add route: `<Route path="/admin/audit-log" element={<Suspense fallback={<LoadingFallback />}><AdminAuditLog /></Suspense>} />` alongside existing admin routes.
4. Update `frontend/src/components/AdminDropdown.tsx`:
- Add an "Audit Log" link to `/admin/audit-log` in the dropdown menu, after the "Users" link.
## Must-Haves
- [ ] `/admin/audit-log` route renders the AdminAuditLog page
- [ ] Table displays impersonation log entries with correct columns
- [ ] Pagination controls work (page state updates, new data fetched)
- [ ] AdminDropdown includes Audit Log link
- [ ] `npm run build` passes with zero errors
- Estimate: 45m
- Files: frontend/src/pages/AdminAuditLog.tsx, frontend/src/pages/AdminAuditLog.module.css, frontend/src/App.tsx, frontend/src/components/AdminDropdown.tsx
- Verify: cd frontend && npm run build 2>&1 | tail -5

View file

@ -0,0 +1,85 @@
# S07 Research — Impersonation Polish + Write Mode
## Depth: Light Research
This is straightforward UI+API work layering two features onto an already-solid impersonation system. No new tech, no risky integration.
## Summary
The slice delivers three things:
1. **Impersonation write mode** — currently `reject_impersonation` blocks ALL writes during impersonation. Add an opt-in "write mode" with a confirmation modal so admins can edit on behalf of a creator.
2. **Confirmation modal** — before entering write mode, show a modal warning the admin.
3. **Audit log admin view** — new page showing all impersonation sessions (start/stop, who, when, IP).
## Requirement Coverage
No specific active requirements are owned by this slice. It advances operational readiness and admin tooling (good governance for the impersonation feature established in earlier milestones).
## Implementation Landscape
### Backend — What Exists
| File | What it does | Relevance |
|------|-------------|-----------|
| `backend/auth.py` | `create_impersonation_token()` — JWT with `original_user_id` + `type: "impersonation"`. `reject_impersonation()` — dependency that blocks ALL writes. | Core target. Write mode needs a token claim or request-level flag to bypass the blanket reject. |
| `backend/routers/admin.py` | `POST /admin/impersonate/{user_id}` (start), `POST /admin/impersonate/stop` (stop). `GET /admin/users` (list). | Add `GET /admin/impersonation-log` here. Modify start to accept `write_mode` param. |
| `backend/models.py:680` | `ImpersonationLog` model — `id, admin_user_id, target_user_id, action, ip_address, created_at`. Already in DB. | Query target for audit log endpoint. May want to add `write_mode` boolean column. |
| `backend/routers/auth.py:134` | `PUT /auth/me` uses `reject_impersonation` dependency. | Will need to conditionally allow writes when write mode is active. |
| `backend/routers/consent.py:178` | Consent PUT also uses `reject_impersonation`. | Same — conditionally allow. |
### Frontend — What Exists
| File | What it does | Relevance |
|------|-------------|-----------|
| `frontend/src/pages/AdminUsers.tsx` | Users table with "View As" button per creator. Calls `startImpersonation()`. | Add write-mode toggle or make the confirmation modal launch from here. |
| `frontend/src/components/ImpersonationBanner.tsx` | Fixed amber banner: "Viewing as {name}" + Exit button. Adds `body.impersonating` class. | Add write-mode indicator (e.g. "Editing as {name}" in red) and toggle button. |
| `frontend/src/context/AuthContext.tsx` | `startImpersonation()`, `exitImpersonation()`, `isImpersonating` state. Stores admin token in sessionStorage. | Add `isWriteMode` state. Possibly pass write_mode flag to start endpoint. |
| `frontend/src/api/auth.ts` | API functions: `impersonateUser()`, `stopImpersonation()`, `fetchUsers()`. Types: `ImpersonateResponse`, `UserResponse.impersonating`. | Add `fetchImpersonationLog()` function. Add `write_mode` param to `impersonateUser()`. |
| `frontend/src/components/AdminDropdown.tsx` | Admin nav dropdown with links to Reports, Pipeline, Techniques, Users. | Add "Audit Log" link. |
| `frontend/src/App.tsx:186-189` | Admin routes: `/admin/reports`, `/admin/pipeline`, `/admin/techniques`, `/admin/users`. | Add `/admin/audit-log` route. |
### Key Design Decisions Needed
1. **Write mode scope**: Two approaches:
- **Token-level**: Encode `write_mode: true` in the impersonation JWT at creation time. Simpler but requires re-issuing token to toggle.
- **Request-level**: Keep current token, add a separate `POST /admin/impersonate/write-mode` toggle that flips a server-side session flag or issues a new token. More flexible.
- **Recommendation**: Token-level. Issue a new impersonation token with `write_mode: true` claim when admin confirms the modal. `reject_impersonation` checks this claim. Simple, stateless, auditable.
2. **Confirmation modal**: No existing modal component in the codebase. Need to create a reusable `ConfirmModal` or a one-off. Since there's no modal lib, a simple CSS-based overlay is the right call — matches the existing hand-rolled component style.
3. **Audit log endpoint**: Simple paginated GET returning `ImpersonationLog` rows joined with user display names.
## Natural Seams (Task Decomposition)
1. **Backend: Write-mode token + audit log endpoint** (~independent)
- Add `write_mode` boolean claim to impersonation token
- Modify `reject_impersonation` to allow writes when `write_mode=true` in token
- Add `write_mode` column to `ImpersonationLog` model
- Add `GET /admin/impersonation-log` endpoint (paginated, admin-only)
- Add `write_mode` param to `POST /admin/impersonate/{user_id}`
- Files: `backend/auth.py`, `backend/routers/admin.py`, `backend/models.py`, `backend/schemas.py`
2. **Frontend: Confirmation modal + write-mode banner** (~depends on T01 API)
- Create `ConfirmModal` component (reusable overlay)
- Wire confirmation flow into `AdminUsers.tsx` "View As" button — add "View As (Read)" vs "Edit As (Write)" options, or a checkbox before confirming
- Update `ImpersonationBanner.tsx` to show write-mode state (red vs amber)
- Update `AuthContext.tsx` to track and expose `isWriteMode`
- Update `api/auth.ts` to pass `write_mode` to start endpoint
- Files: `frontend/src/components/ConfirmModal.tsx`, `frontend/src/components/ConfirmModal.module.css`, `frontend/src/pages/AdminUsers.tsx`, `frontend/src/components/ImpersonationBanner.tsx`, `frontend/src/context/AuthContext.tsx`, `frontend/src/api/auth.ts`
3. **Frontend: Audit log admin page** (~depends on T01 API)
- New `AdminAuditLog.tsx` page with table of sessions
- Add route in `App.tsx`
- Add link in `AdminDropdown.tsx`
- Add API function in `api/auth.ts`
- Files: `frontend/src/pages/AdminAuditLog.tsx`, `frontend/src/pages/AdminAuditLog.module.css`, `frontend/src/App.tsx`, `frontend/src/components/AdminDropdown.tsx`, `frontend/src/api/auth.ts`
## Verification Strategy
- Backend: Unit tests for `reject_impersonation` allowing writes with write_mode token, blocking without. Test audit log endpoint returns rows.
- Frontend: Build passes (`npm run build`). Visual check that confirmation modal appears, banner changes color in write mode, audit log page renders.
- Integration: Start impersonation with write_mode=true → perform a write → verify it succeeds and audit log records the session.
## Risks
None significant. This is additive feature work on established patterns. The only mild risk is DB migration for the `write_mode` column on `ImpersonationLog` — the project doesn't use Alembic migrations (tables are created via `metadata.create_all`), so the column needs a default or manual `ALTER TABLE`.

View file

@ -0,0 +1,56 @@
---
estimated_steps: 23
estimated_files: 4
skills_used: []
---
# T01: Backend: write-mode token, conditional reject, audit log endpoint + tests
Add write_mode support to impersonation tokens and a paginated audit log endpoint.
## Steps
1. In `backend/models.py`, add `write_mode: Mapped[bool]` column to `ImpersonationLog` with `default=False, server_default=text('false')`. Import `text` from sqlalchemy if needed.
2. In `backend/auth.py`:
- Modify `create_impersonation_token()` to accept an optional `write_mode: bool = False` param. When True, add `"write_mode": True` to the JWT payload.
- Modify `get_current_user()` to also attach `_impersonation_write_mode` from the decoded payload: `user._impersonation_write_mode = payload.get("write_mode", False)`.
- Modify `reject_impersonation()` to check `_impersonation_write_mode` — if True, allow the request through (return user). Only block when impersonating WITHOUT write mode.
3. In `backend/routers/admin.py`:
- Add a Pydantic request body model `StartImpersonationRequest` with `write_mode: bool = False`.
- Modify `start_impersonation()` to accept this body, pass `write_mode` to `create_impersonation_token()`, and log `write_mode` in the `ImpersonationLog` row.
- Add response schema `ImpersonationLogItem(BaseModel)` with fields: `id`, `admin_name`, `target_name`, `action`, `write_mode`, `ip_address`, `created_at`.
- Add `GET /impersonation-log` endpoint (admin-only) that queries `ImpersonationLog` joined with `User` (aliased for admin and target) for display names. Support `?page=1&page_size=50` query params. Return `list[ImpersonationLogItem]`.
4. In `backend/tests/test_impersonation.py` (new file), write integration tests:
- Test: start impersonation without write_mode → write to `PUT /auth/me` is 403.
- Test: start impersonation with write_mode=true → write to `PUT /auth/me` succeeds (or at least is not 403 from reject_impersonation — may get validation error, that's fine).
- Test: `GET /admin/impersonation-log` returns log entries with admin/target names.
- Test: non-admin cannot access impersonation-log.
## Must-Haves
- [ ] `reject_impersonation` allows writes when token has `write_mode=true`
- [ ] `reject_impersonation` still blocks writes when `write_mode` absent or false
- [ ] `ImpersonationLog` records `write_mode` boolean
- [ ] `GET /admin/impersonation-log` returns paginated entries with display names
- [ ] All new tests pass
## Inputs
- ``backend/auth.py` — existing impersonation token creation and reject_impersonation dependency`
- ``backend/models.py` — existing ImpersonationLog model at line 680`
- ``backend/routers/admin.py` — existing start/stop impersonation endpoints and schemas`
- ``backend/tests/conftest.py` — test fixtures (client, db_engine, etc.)`
## Expected Output
- ``backend/auth.py` — modified with write_mode param in create_impersonation_token, _impersonation_write_mode on user, conditional reject_impersonation`
- ``backend/models.py` — ImpersonationLog.write_mode column added`
- ``backend/routers/admin.py` — StartImpersonationRequest body, write_mode passed through, GET /impersonation-log endpoint`
- ``backend/tests/test_impersonation.py` — new integration test file with 4+ test cases`
## Verification
cd backend && python -m pytest tests/test_impersonation.py -v

View file

@ -0,0 +1,81 @@
---
id: T01
parent: S07
milestone: M021
provides: []
requires: []
affects: []
key_files: ["backend/auth.py", "backend/models.py", "backend/routers/admin.py", "backend/tests/test_impersonation.py"]
key_decisions: ["StartImpersonationRequest body is optional for backward compatibility", "write_mode only added to JWT payload when True to keep read-only tokens minimal"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "All 5 integration tests pass: test_impersonation_without_write_mode_blocks_writes, test_impersonation_with_write_mode_allows_writes, test_impersonation_log_returns_entries, test_impersonation_log_non_admin_forbidden, test_impersonation_log_pagination. Run against real PostgreSQL test database via SSH tunnel."
completed_at: 2026-04-04T06:23:49.140Z
blocker_discovered: false
---
# T01: Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint
> Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint
## What Happened
---
id: T01
parent: S07
milestone: M021
key_files:
- backend/auth.py
- backend/models.py
- backend/routers/admin.py
- backend/tests/test_impersonation.py
key_decisions:
- StartImpersonationRequest body is optional for backward compatibility
- write_mode only added to JWT payload when True to keep read-only tokens minimal
duration: ""
verification_result: passed
completed_at: 2026-04-04T06:23:49.140Z
blocker_discovered: false
---
# T01: Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint
**Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint**
## What Happened
Added write_mode boolean column to ImpersonationLog model. Modified create_impersonation_token to accept write_mode param and embed it in JWT payload. Updated get_current_user to attach _impersonation_write_mode from token. Made reject_impersonation conditional — allows writes when write_mode is True, blocks when False or absent. Added StartImpersonationRequest body model, ImpersonationLogItem response schema, and GET /impersonation-log admin endpoint with aliased User joins for display names and pagination. Created 5 integration tests covering all must-haves.
## Verification
All 5 integration tests pass: test_impersonation_without_write_mode_blocks_writes, test_impersonation_with_write_mode_allows_writes, test_impersonation_log_returns_entries, test_impersonation_log_non_admin_forbidden, test_impersonation_log_pagination. Run against real PostgreSQL test database via SSH tunnel.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `python -m pytest tests/test_impersonation.py -v` | 0 | ✅ pass | 7430ms |
## Deviations
StartImpersonationRequest body parameter made optional (defaults to None) for backward compatibility with existing callers that POST without a body.
## Known Issues
None.
## Files Created/Modified
- `backend/auth.py`
- `backend/models.py`
- `backend/routers/admin.py`
- `backend/tests/test_impersonation.py`
## Deviations
StartImpersonationRequest body parameter made optional (defaults to None) for backward compatibility with existing callers that POST without a body.
## Known Issues
None.

View file

@ -0,0 +1,71 @@
---
estimated_steps: 32
estimated_files: 7
skills_used: []
---
# T02: Frontend: confirmation modal, write-mode banner state, API write_mode param
Add a confirmation modal for write-mode impersonation and update the banner to reflect mode.
## Steps
1. Create `frontend/src/components/ConfirmModal.tsx` — a reusable overlay modal:
- Props: `open: boolean`, `title: string`, `message: string`, `confirmLabel?: string`, `cancelLabel?: string`, `onConfirm: () => void`, `onCancel: () => void`, `variant?: 'warning' | 'danger'`.
- Renders a backdrop + centered card with title, message, cancel button, confirm button.
- Confirm button uses red/amber depending on variant.
- Closes on Escape key and backdrop click.
2. Create `frontend/src/components/ConfirmModal.module.css` — modal styling matching the dark theme.
3. Update `frontend/src/api/auth.ts`:
- Modify `impersonateUser()` to accept optional `writeMode?: boolean` param. When true, send `{ write_mode: true }` as JSON body with `Content-Type: application/json`.
- Add `ImpersonationLogEntry` interface: `{ id, admin_name, target_name, action, write_mode, ip_address, created_at }`.
- Add `fetchImpersonationLog(token: string, page?: number): Promise<ImpersonationLogEntry[]>` function calling `GET /admin/impersonation-log?page=N`.
4. Update `frontend/src/context/AuthContext.tsx`:
- Add `isWriteMode: boolean` to context value.
- Track write mode in state. When `startImpersonation` is called, accept optional `writeMode` param and pass to API.
- Expose `isWriteMode` in context.
5. Update `frontend/src/pages/AdminUsers.tsx`:
- Replace the single "View As" button with two buttons: "View As" (read-only) and "Edit As" (write mode).
- "Edit As" opens the ConfirmModal with a warning: "You are about to edit as {name}. Changes will be attributed to this creator and logged. Continue?"
- On confirm, call `startImpersonation(userId, true)`.
- "View As" works as before (no modal, read-only mode).
6. Update `frontend/src/components/ImpersonationBanner.tsx`:
- Pull `isWriteMode` from auth context.
- When write mode: banner background red (#dc2626), icon ✏️, text "Editing as {name}".
- When read mode: keep current amber (#b45309), icon 👁, text "Viewing as {name}".
- Add `body.impersonating-write` class in write mode for potential downstream styling.
## Must-Haves
- [ ] ConfirmModal component renders with backdrop, closes on Escape/backdrop click
- [ ] "Edit As" button shows confirmation modal before starting write-mode impersonation
- [ ] Banner shows red "Editing as" in write mode, amber "Viewing as" in read mode
- [ ] `impersonateUser` API function sends write_mode in request body
- [ ] `npm run build` passes with zero errors
## Inputs
- ``frontend/src/api/auth.ts` — existing impersonateUser and stopImpersonation functions`
- ``frontend/src/context/AuthContext.tsx` — existing auth context with startImpersonation`
- ``frontend/src/pages/AdminUsers.tsx` — existing admin users page with View As button`
- ``frontend/src/components/ImpersonationBanner.tsx` — existing banner component`
- ``frontend/src/components/ImpersonationBanner.module.css` — existing banner styles`
## Expected Output
- ``frontend/src/components/ConfirmModal.tsx` — new reusable modal component`
- ``frontend/src/components/ConfirmModal.module.css` — new modal styles`
- ``frontend/src/api/auth.ts` — modified with write_mode param, ImpersonationLogEntry type, fetchImpersonationLog function`
- ``frontend/src/context/AuthContext.tsx` — modified with isWriteMode state and writeMode param on startImpersonation`
- ``frontend/src/pages/AdminUsers.tsx` — modified with Edit As button and confirmation modal`
- ``frontend/src/components/ImpersonationBanner.tsx` — modified with write-mode visual state`
- ``frontend/src/components/ImpersonationBanner.module.css` — modified with write-mode red style`
## Verification
cd frontend && npm run build 2>&1 | tail -5

View file

@ -0,0 +1,55 @@
---
estimated_steps: 21
estimated_files: 4
skills_used: []
---
# T03: Frontend: audit log admin page, route, and nav link
Add an admin page displaying paginated impersonation audit log entries.
## Steps
1. Create `frontend/src/pages/AdminAuditLog.tsx`:
- Use `useDocumentTitle('Audit Log — Admin')` like other admin pages.
- Fetch impersonation log via `fetchImpersonationLog(token)` from `api/auth.ts` (added in T02).
- Render a table with columns: Date/Time, Admin, Target User, Action (start/stop), Write Mode (yes/no badge), IP Address.
- Format `created_at` as locale datetime string.
- Show loading and error states matching existing admin page patterns (see AdminUsers.tsx).
- Add simple pagination: "Previous" / "Next" buttons, tracking current page in state.
2. Create `frontend/src/pages/AdminAuditLog.module.css` — table styling matching AdminUsers.module.css patterns.
3. Update `frontend/src/App.tsx`:
- Add lazy import: `const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));`
- Add route: `<Route path="/admin/audit-log" element={<Suspense fallback={<LoadingFallback />}><AdminAuditLog /></Suspense>} />` alongside existing admin routes.
4. Update `frontend/src/components/AdminDropdown.tsx`:
- Add an "Audit Log" link to `/admin/audit-log` in the dropdown menu, after the "Users" link.
## Must-Haves
- [ ] `/admin/audit-log` route renders the AdminAuditLog page
- [ ] Table displays impersonation log entries with correct columns
- [ ] Pagination controls work (page state updates, new data fetched)
- [ ] AdminDropdown includes Audit Log link
- [ ] `npm run build` passes with zero errors
## Inputs
- ``frontend/src/api/auth.ts` — fetchImpersonationLog function and ImpersonationLogEntry type (added in T02)`
- ``frontend/src/App.tsx` — existing admin routes to add alongside`
- ``frontend/src/components/AdminDropdown.tsx` — existing dropdown to add link to`
- ``frontend/src/pages/AdminUsers.tsx` — reference for admin page patterns and CSS module conventions`
- ``frontend/src/pages/AdminUsers.module.css` — reference for table styling patterns`
## Expected Output
- ``frontend/src/pages/AdminAuditLog.tsx` — new audit log admin page`
- ``frontend/src/pages/AdminAuditLog.module.css` — new page styles`
- ``frontend/src/App.tsx` — modified with /admin/audit-log route`
- ``frontend/src/components/AdminDropdown.tsx` — modified with Audit Log link`
## Verification
cd frontend && npm run build 2>&1 | tail -5

View file

@ -63,11 +63,14 @@ def create_impersonation_token(
admin_user_id: uuid.UUID | str,
target_user_id: uuid.UUID | str,
target_role: str,
*,
write_mode: bool = False,
) -> str:
"""Create a scoped JWT for admin impersonation.
The token has sub=target_user_id so get_current_user loads the target,
plus original_user_id so the system knows it's impersonation.
When write_mode is True, the token allows write operations.
"""
settings = get_settings()
now = datetime.now(timezone.utc)
@ -79,6 +82,8 @@ def create_impersonation_token(
"iat": now,
"exp": now + timedelta(minutes=_IMPERSONATION_EXPIRE_MINUTES),
}
if write_mode:
payload["write_mode"] = True
return jwt.encode(payload, settings.app_secret_key, algorithm=_ALGORITHM)
@ -127,6 +132,7 @@ async def get_current_user(
)
# Attach impersonation metadata (non-column runtime attribute)
user._impersonating_admin_id = payload.get("original_user_id") # type: ignore[attr-defined]
user._impersonation_write_mode = payload.get("write_mode", False) # type: ignore[attr-defined]
return user
@ -149,9 +155,15 @@ def require_role(required_role: UserRole):
async def reject_impersonation(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Dependency that blocks write operations during impersonation."""
"""Dependency that blocks write operations during impersonation.
If the impersonation token was issued with write_mode=True,
writes are permitted.
"""
admin_id = getattr(current_user, "_impersonating_admin_id", None)
if admin_id is not None:
write_mode = getattr(current_user, "_impersonation_write_mode", False)
if not write_mode:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Write operations are not allowed during impersonation",

View file

@ -21,6 +21,7 @@ from sqlalchemy import (
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
@ -691,6 +692,9 @@ class ImpersonationLog(Base):
action: Mapped[str] = mapped_column(
String(10), nullable=False, doc="'start' or 'stop'"
)
write_mode: Mapped[bool] = mapped_column(
default=False, server_default=text("false"),
)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()

View file

@ -3,13 +3,15 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from auth import (
create_impersonation_token,
@ -52,6 +54,20 @@ class StopImpersonateResponse(BaseModel):
message: str
class StartImpersonationRequest(BaseModel):
write_mode: bool = False
class ImpersonationLogItem(BaseModel):
id: str
admin_name: str
target_name: str
action: str
write_mode: bool
ip_address: str | None
created_at: datetime
# ── Helpers ──────────────────────────────────────────────────────────────────
@ -97,8 +113,12 @@ async def start_impersonation(
request: Request,
admin: Annotated[User, Depends(_require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
body: StartImpersonationRequest | None = None,
):
"""Start impersonating a user. Admin only. Returns a scoped JWT."""
if body is None:
body = StartImpersonationRequest()
# Cannot impersonate yourself
if admin.id == user_id:
raise HTTPException(
@ -120,6 +140,7 @@ async def start_impersonation(
admin_user_id=admin.id,
target_user_id=target.id,
target_role=target.role.value,
write_mode=body.write_mode,
)
# Audit log
@ -127,13 +148,14 @@ async def start_impersonation(
admin_user_id=admin.id,
target_user_id=target.id,
action="start",
write_mode=body.write_mode,
ip_address=_client_ip(request),
))
await session.commit()
logger.info(
"Impersonation started: admin=%s target=%s",
admin.id, target.id,
"Impersonation started: admin=%s target=%s write_mode=%s",
admin.id, target.id, body.write_mode,
)
return ImpersonateResponse(
@ -178,3 +200,39 @@ async def stop_impersonation(
)
return StopImpersonateResponse(message="Impersonation ended")
@router.get("/impersonation-log", response_model=list[ImpersonationLogItem])
async def get_impersonation_log(
_admin: Annotated[User, Depends(_require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
):
"""Paginated impersonation audit log. Admin only."""
AdminUser = aliased(User, name="admin_user")
TargetUser = aliased(User, name="target_user")
stmt = (
select(ImpersonationLog, AdminUser.display_name, TargetUser.display_name)
.join(AdminUser, ImpersonationLog.admin_user_id == AdminUser.id)
.join(TargetUser, ImpersonationLog.target_user_id == TargetUser.id)
.order_by(ImpersonationLog.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await session.execute(stmt)
rows = result.all()
return [
ImpersonationLogItem(
id=str(log.id),
admin_name=admin_name,
target_name=target_name,
action=log.action,
write_mode=log.write_mode,
ip_address=log.ip_address,
created_at=log.created_at,
)
for log, admin_name, target_name in rows
]

View file

@ -0,0 +1,174 @@
"""Integration tests for impersonation write-mode and audit log."""
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from models import InviteCode, User, UserRole
# Re-use fixtures from conftest: db_engine, client, admin_auth
_TARGET_EMAIL = "impersonate-target@chrysopedia.com"
_TARGET_PASSWORD = "targetpass123"
_TARGET_INVITE = "IMP-TARGET-INV"
@pytest_asyncio.fixture()
async def target_user(client: AsyncClient, db_engine):
"""Register a regular user to be the impersonation target."""
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
code = InviteCode(code=_TARGET_INVITE, uses_remaining=10)
session.add(code)
await session.commit()
resp = await client.post("/api/v1/auth/register", json={
"email": _TARGET_EMAIL,
"password": _TARGET_PASSWORD,
"display_name": "Target User",
"invite_code": _TARGET_INVITE,
})
assert resp.status_code == 201
return resp.json()
# ── Write-mode tests ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_impersonation_without_write_mode_blocks_writes(
client: AsyncClient, admin_auth, target_user,
):
"""Read-only impersonation (default) should 403 on PUT /auth/me."""
# Start impersonation without write_mode
resp = await client.post(
f"/api/v1/admin/impersonate/{target_user['id']}",
headers=admin_auth["headers"],
)
assert resp.status_code == 200
imp_token = resp.json()["access_token"]
imp_headers = {"Authorization": f"Bearer {imp_token}"}
# Attempt a write operation — should be blocked
resp = await client.put(
"/api/v1/auth/me",
headers=imp_headers,
json={"display_name": "Hacked Name"},
)
assert resp.status_code == 403
assert "impersonation" in resp.json()["detail"].lower()
@pytest.mark.asyncio
async def test_impersonation_with_write_mode_allows_writes(
client: AsyncClient, admin_auth, target_user,
):
"""Write-mode impersonation should not 403 on PUT /auth/me."""
# Start impersonation WITH write_mode
resp = await client.post(
f"/api/v1/admin/impersonate/{target_user['id']}",
headers=admin_auth["headers"],
json={"write_mode": True},
)
assert resp.status_code == 200
imp_token = resp.json()["access_token"]
imp_headers = {"Authorization": f"Bearer {imp_token}"}
# Attempt a write — should NOT get 403 from reject_impersonation
resp = await client.put(
"/api/v1/auth/me",
headers=imp_headers,
json={"display_name": "Updated Via WriteMode"},
)
# Should succeed (200) or at least not be a 403
assert resp.status_code != 403
# Verify the update actually took effect
assert resp.status_code == 200
assert resp.json()["display_name"] == "Updated Via WriteMode"
# ── Audit log endpoint tests ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_impersonation_log_returns_entries(
client: AsyncClient, admin_auth, target_user,
):
"""GET /admin/impersonation-log returns log entries with names."""
# Create some log entries by starting impersonation
resp = await client.post(
f"/api/v1/admin/impersonate/{target_user['id']}",
headers=admin_auth["headers"],
json={"write_mode": True},
)
assert resp.status_code == 200
# Fetch the log
resp = await client.get(
"/api/v1/admin/impersonation-log",
headers=admin_auth["headers"],
)
assert resp.status_code == 200
logs = resp.json()
assert len(logs) >= 1
entry = logs[0]
assert entry["admin_name"] == "Admin User"
assert entry["target_name"] == "Target User"
assert entry["action"] == "start"
assert entry["write_mode"] is True
assert "id" in entry
assert "created_at" in entry
@pytest.mark.asyncio
async def test_impersonation_log_non_admin_forbidden(
client: AsyncClient, target_user,
):
"""Non-admin users cannot access the impersonation log."""
# Login as the target (regular) user
resp = await client.post("/api/v1/auth/login", json={
"email": _TARGET_EMAIL,
"password": _TARGET_PASSWORD,
})
assert resp.status_code == 200
user_headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
resp = await client.get(
"/api/v1/admin/impersonation-log",
headers=user_headers,
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_impersonation_log_pagination(
client: AsyncClient, admin_auth, target_user,
):
"""Verify pagination params work on impersonation-log."""
# Create two entries
for _ in range(2):
await client.post(
f"/api/v1/admin/impersonate/{target_user['id']}",
headers=admin_auth["headers"],
)
# Fetch page 1, page_size=1
resp = await client.get(
"/api/v1/admin/impersonation-log",
headers=admin_auth["headers"],
params={"page": 1, "page_size": 1},
)
assert resp.status_code == 200
assert len(resp.json()) == 1
# Fetch page 2
resp = await client.get(
"/api/v1/admin/impersonation-log",
headers=admin_auth["headers"],
params={"page": 2, "page_size": 1},
)
assert resp.status_code == 200
assert len(resp.json()) == 1