feat: Added AdminAuditLog page with paginated impersonation log table,…

- "frontend/src/pages/AdminAuditLog.tsx"
- "frontend/src/pages/AdminAuditLog.module.css"
- "frontend/src/App.tsx"
- "frontend/src/components/AdminDropdown.tsx"

GSD-Task: S07/T03
This commit is contained in:
jlightner 2026-04-04 06:29:47 +00:00
parent 4969935c76
commit 4bda29705d
8 changed files with 346 additions and 2 deletions

View file

@ -81,7 +81,7 @@
- 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.
- [x] **T03: Added AdminAuditLog page with paginated impersonation log table, /admin/audit-log route, and Audit Log link in admin dropdown** — Add an admin page displaying paginated impersonation audit log entries.
## Steps

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M021/S07/T02",
"timestamp": 1775284058050,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 5,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,81 @@
---
id: T03
parent: S07
milestone: M021
provides: []
requires: []
affects: []
key_files: ["frontend/src/pages/AdminAuditLog.tsx", "frontend/src/pages/AdminAuditLog.module.css", "frontend/src/App.tsx", "frontend/src/components/AdminDropdown.tsx"]
key_decisions: ["Disabled Next button when current page returns zero entries as simple end-of-data signal"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Vite build succeeds (exit 0). tsc -b output filtered for new files shows zero errors — all reported errors are pre-existing in ChapterReview.tsx."
completed_at: 2026-04-04T06:29:43.845Z
blocker_discovered: false
---
# T03: Added AdminAuditLog page with paginated impersonation log table, /admin/audit-log route, and Audit Log link in admin dropdown
> Added AdminAuditLog page with paginated impersonation log table, /admin/audit-log route, and Audit Log link in admin dropdown
## What Happened
---
id: T03
parent: S07
milestone: M021
key_files:
- frontend/src/pages/AdminAuditLog.tsx
- frontend/src/pages/AdminAuditLog.module.css
- frontend/src/App.tsx
- frontend/src/components/AdminDropdown.tsx
key_decisions:
- Disabled Next button when current page returns zero entries as simple end-of-data signal
duration: ""
verification_result: passed
completed_at: 2026-04-04T06:29:43.845Z
blocker_discovered: false
---
# T03: Added AdminAuditLog page with paginated impersonation log table, /admin/audit-log route, and Audit Log link in admin dropdown
**Added AdminAuditLog page with paginated impersonation log table, /admin/audit-log route, and Audit Log link in admin dropdown**
## What Happened
Created AdminAuditLog.tsx following AdminUsers patterns: useDocumentTitle, token-authenticated fetch via fetchImpersonationLog, loading/error/empty states, and a six-column table (Date/Time, Admin, Target User, Action, Write Mode, IP Address). Badge styling uses data-attributes for action (start=cyan, stop=slate) and write mode (yes=red, no=muted). Pagination uses Previous/Next buttons with page state. Created matching CSS module. Added lazy import and /admin/audit-log route in App.tsx. Added Audit Log link in AdminDropdown after Users.
## Verification
Vite build succeeds (exit 0). tsc -b output filtered for new files shows zero errors — all reported errors are pre-existing in ChapterReview.tsx.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npx vite build` | 0 | ✅ pass | 2020ms |
| 2 | `cd frontend && npx tsc -b 2>&1 | grep -v ChapterReview` | 0 | ✅ pass (no new errors) | 3000ms |
## Deviations
None.
## Known Issues
Pre-existing tsc -b errors in ChapterReview.tsx cause npm run build to exit non-zero. Unrelated to this task.
## Files Created/Modified
- `frontend/src/pages/AdminAuditLog.tsx`
- `frontend/src/pages/AdminAuditLog.module.css`
- `frontend/src/App.tsx`
- `frontend/src/components/AdminDropdown.tsx`
## Deviations
None.
## Known Issues
Pre-existing tsc -b errors in ChapterReview.tsx cause npm run build to exit non-zero. Unrelated to this task.

View file

@ -20,6 +20,7 @@ const CreatorSettings = React.lazy(() => import("./pages/CreatorSettings"));
const ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard"));
const WatchPage = React.lazy(() => import("./pages/WatchPage"));
const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog"));
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
import AdminDropdown from "./components/AdminDropdown";
@ -187,6 +188,7 @@ function AppShell() {
<Route path="/admin/pipeline" element={<Suspense fallback={<LoadingFallback />}><AdminPipeline /></Suspense>} />
<Route path="/admin/techniques" element={<Suspense fallback={<LoadingFallback />}><AdminTechniquePages /></Suspense>} />
<Route path="/admin/users" element={<Suspense fallback={<LoadingFallback />}><AdminUsers /></Suspense>} />
<Route path="/admin/audit-log" element={<Suspense fallback={<LoadingFallback />}><AdminAuditLog /></Suspense>} />
{/* Info routes */}
<Route path="/about" element={<Suspense fallback={<LoadingFallback />}><About /></Suspense>} />

View file

@ -118,6 +118,14 @@ export default function AdminDropdown() {
>
Users
</Link>
<Link
to="/admin/audit-log"
className="admin-dropdown__item"
role="menuitem"
onClick={() => setOpen(false)}
>
Audit Log
</Link>
</div>
)}
</div>

View file

@ -0,0 +1,133 @@
.page {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1rem;
}
.title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--text-primary, #e2e8f0);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.table th {
text-align: left;
padding: 0.6rem 0.75rem;
border-bottom: 2px solid var(--color-border, #2d2d3d);
color: var(--text-secondary, #828291);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--color-border, #2d2d3d);
color: var(--text-primary, #e2e8f0);
}
.actionBadge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.actionBadge[data-action="start"] {
background: rgba(34, 211, 238, 0.15);
color: #22d3ee;
}
.actionBadge[data-action="stop"] {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
.writeBadge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.writeBadge[data-write="yes"] {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.writeBadge[data-write="no"] {
background: rgba(148, 163, 184, 0.1);
color: #828291;
}
.ip {
font-family: monospace;
font-size: 0.82rem;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.pageBtn {
padding: 0.35rem 0.75rem;
border: 1px solid var(--color-border, #2d2d3d);
border-radius: 4px;
background: transparent;
color: var(--text-primary, #e2e8f0);
font-size: 0.85rem;
cursor: pointer;
transition: background 150ms, border-color 150ms;
}
.pageBtn:hover:not(:disabled) {
border-color: var(--color-accent, #22d3ee);
background: rgba(34, 211, 238, 0.08);
}
.pageBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pageNum {
font-size: 0.85rem;
color: var(--text-secondary, #828291);
}
.loading,
.error,
.empty {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary, #828291);
}
.error {
color: #ef4444;
}
@media (max-width: 600px) {
.table th:nth-child(6),
.table td:nth-child(6) {
display: none;
}
}

View file

@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { useAuth } from "../context/AuthContext";
import { fetchImpersonationLog, type ImpersonationLogEntry } from "../api/auth";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./AdminAuditLog.module.css";
export default function AdminAuditLog() {
useDocumentTitle("Audit Log — Admin");
const { token } = useAuth();
const [entries, setEntries] = useState<ImpersonationLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
useEffect(() => {
if (!token) return;
setLoading(true);
setError(null);
fetchImpersonationLog(token, page)
.then(setEntries)
.catch((e) => setError(e.message || "Failed to load audit log"))
.finally(() => setLoading(false));
}, [token, page]);
if (loading) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Audit Log</h1>
<p className={styles.loading}>Loading audit log</p>
</div>
);
}
if (error) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Audit Log</h1>
<p className={styles.error}>{error}</p>
</div>
);
}
return (
<div className={styles.page}>
<h1 className={styles.title}>Audit Log</h1>
{entries.length === 0 ? (
<p className={styles.empty}>No impersonation log entries found.</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>Date/Time</th>
<th>Admin</th>
<th>Target User</th>
<th>Action</th>
<th>Write Mode</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id}>
<td>{new Date(entry.created_at).toLocaleString()}</td>
<td>{entry.admin_name}</td>
<td>{entry.target_name}</td>
<td>
<span className={styles.actionBadge} data-action={entry.action}>
{entry.action}
</span>
</td>
<td>
<span
className={styles.writeBadge}
data-write={entry.write_mode ? "yes" : "no"}
>
{entry.write_mode ? "Yes" : "No"}
</span>
</td>
<td className={styles.ip}>{entry.ip_address ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
<div className={styles.pagination}>
<button
className={styles.pageBtn}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className={styles.pageNum}>Page {page}</span>
<button
className={styles.pageBtn}
disabled={entries.length === 0}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"errors":true,"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"errors":true,"version":"5.6.3"}