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:
parent
f822415f6f
commit
ab9dd2aa1b
14 changed files with 935 additions and 10 deletions
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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. |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
116
.gsd/milestones/M021/slices/S06/S06-SUMMARY.md
Normal file
116
.gsd/milestones/M021/slices/S06/S06-SUMMARY.md
Normal 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
|
||||||
75
.gsd/milestones/M021/slices/S06/S06-UAT.md
Normal file
75
.gsd/milestones/M021/slices/S06/S06-UAT.md
Normal 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
|
||||||
30
.gsd/milestones/M021/slices/S06/tasks/T03-VERIFY.json
Normal file
30
.gsd/milestones/M021/slices/S06/tasks/T03-VERIFY.json
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,114 @@
|
||||||
# S07: [A] Impersonation Polish + Write Mode
|
# 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.
|
**Demo:** After this: Impersonation write mode with confirmation modal. Audit log admin view shows all sessions.
|
||||||
|
|
||||||
## Tasks
|
## 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
|
||||||
|
|
|
||||||
85
.gsd/milestones/M021/slices/S07/S07-RESEARCH.md
Normal file
85
.gsd/milestones/M021/slices/S07/S07-RESEARCH.md
Normal 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`.
|
||||||
56
.gsd/milestones/M021/slices/S07/tasks/T01-PLAN.md
Normal file
56
.gsd/milestones/M021/slices/S07/tasks/T01-PLAN.md
Normal 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
|
||||||
81
.gsd/milestones/M021/slices/S07/tasks/T01-SUMMARY.md
Normal file
81
.gsd/milestones/M021/slices/S07/tasks/T01-SUMMARY.md
Normal 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.
|
||||||
71
.gsd/milestones/M021/slices/S07/tasks/T02-PLAN.md
Normal file
71
.gsd/milestones/M021/slices/S07/tasks/T02-PLAN.md
Normal 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
|
||||||
55
.gsd/milestones/M021/slices/S07/tasks/T03-PLAN.md
Normal file
55
.gsd/milestones/M021/slices/S07/tasks/T03-PLAN.md
Normal 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
|
||||||
|
|
@ -63,11 +63,14 @@ def create_impersonation_token(
|
||||||
admin_user_id: uuid.UUID | str,
|
admin_user_id: uuid.UUID | str,
|
||||||
target_user_id: uuid.UUID | str,
|
target_user_id: uuid.UUID | str,
|
||||||
target_role: str,
|
target_role: str,
|
||||||
|
*,
|
||||||
|
write_mode: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a scoped JWT for admin impersonation.
|
"""Create a scoped JWT for admin impersonation.
|
||||||
|
|
||||||
The token has sub=target_user_id so get_current_user loads the target,
|
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.
|
plus original_user_id so the system knows it's impersonation.
|
||||||
|
When write_mode is True, the token allows write operations.
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
@ -79,6 +82,8 @@ def create_impersonation_token(
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + timedelta(minutes=_IMPERSONATION_EXPIRE_MINUTES),
|
"exp": now + timedelta(minutes=_IMPERSONATION_EXPIRE_MINUTES),
|
||||||
}
|
}
|
||||||
|
if write_mode:
|
||||||
|
payload["write_mode"] = True
|
||||||
return jwt.encode(payload, settings.app_secret_key, algorithm=_ALGORITHM)
|
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)
|
# Attach impersonation metadata (non-column runtime attribute)
|
||||||
user._impersonating_admin_id = payload.get("original_user_id") # type: ignore[attr-defined]
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -149,11 +155,17 @@ def require_role(required_role: UserRole):
|
||||||
async def reject_impersonation(
|
async def reject_impersonation(
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
) -> 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)
|
admin_id = getattr(current_user, "_impersonating_admin_id", None)
|
||||||
if admin_id is not None:
|
if admin_id is not None:
|
||||||
raise HTTPException(
|
write_mode = getattr(current_user, "_impersonation_write_mode", False)
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
if not write_mode:
|
||||||
detail="Write operations are not allowed during impersonation",
|
raise HTTPException(
|
||||||
)
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Write operations are not allowed during impersonation",
|
||||||
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from sqlalchemy import (
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
@ -691,6 +692,9 @@ class ImpersonationLog(Base):
|
||||||
action: Mapped[str] = mapped_column(
|
action: Mapped[str] = mapped_column(
|
||||||
String(10), nullable=False, doc="'start' or 'stop'"
|
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)
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
default=_now, server_default=func.now()
|
default=_now, server_default=func.now()
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from auth import (
|
from auth import (
|
||||||
create_impersonation_token,
|
create_impersonation_token,
|
||||||
|
|
@ -52,6 +54,20 @@ class StopImpersonateResponse(BaseModel):
|
||||||
message: str
|
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 ──────────────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,8 +113,12 @@ async def start_impersonation(
|
||||||
request: Request,
|
request: Request,
|
||||||
admin: Annotated[User, Depends(_require_admin)],
|
admin: Annotated[User, Depends(_require_admin)],
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
body: StartImpersonationRequest | None = None,
|
||||||
):
|
):
|
||||||
"""Start impersonating a user. Admin only. Returns a scoped JWT."""
|
"""Start impersonating a user. Admin only. Returns a scoped JWT."""
|
||||||
|
if body is None:
|
||||||
|
body = StartImpersonationRequest()
|
||||||
|
|
||||||
# Cannot impersonate yourself
|
# Cannot impersonate yourself
|
||||||
if admin.id == user_id:
|
if admin.id == user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -120,6 +140,7 @@ async def start_impersonation(
|
||||||
admin_user_id=admin.id,
|
admin_user_id=admin.id,
|
||||||
target_user_id=target.id,
|
target_user_id=target.id,
|
||||||
target_role=target.role.value,
|
target_role=target.role.value,
|
||||||
|
write_mode=body.write_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Audit log
|
# Audit log
|
||||||
|
|
@ -127,13 +148,14 @@ async def start_impersonation(
|
||||||
admin_user_id=admin.id,
|
admin_user_id=admin.id,
|
||||||
target_user_id=target.id,
|
target_user_id=target.id,
|
||||||
action="start",
|
action="start",
|
||||||
|
write_mode=body.write_mode,
|
||||||
ip_address=_client_ip(request),
|
ip_address=_client_ip(request),
|
||||||
))
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Impersonation started: admin=%s target=%s",
|
"Impersonation started: admin=%s target=%s write_mode=%s",
|
||||||
admin.id, target.id,
|
admin.id, target.id, body.write_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ImpersonateResponse(
|
return ImpersonateResponse(
|
||||||
|
|
@ -178,3 +200,39 @@ async def stop_impersonation(
|
||||||
)
|
)
|
||||||
|
|
||||||
return StopImpersonateResponse(message="Impersonation ended")
|
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
|
||||||
|
]
|
||||||
|
|
|
||||||
174
backend/tests/test_impersonation.py
Normal file
174
backend/tests/test_impersonation.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue