diff --git a/.gsd/milestones/M021/slices/S07/S07-PLAN.md b/.gsd/milestones/M021/slices/S07/S07-PLAN.md index fd6fdd3..fe835e1 100644 --- a/.gsd/milestones/M021/slices/S07/S07-PLAN.md +++ b/.gsd/milestones/M021/slices/S07/S07-PLAN.md @@ -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 diff --git a/.gsd/milestones/M021/slices/S07/tasks/T02-VERIFY.json b/.gsd/milestones/M021/slices/S07/tasks/T02-VERIFY.json new file mode 100644 index 0000000..02013c3 --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/tasks/T02-VERIFY.json @@ -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" + } + ] +} diff --git a/.gsd/milestones/M021/slices/S07/tasks/T03-SUMMARY.md b/.gsd/milestones/M021/slices/S07/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..c094bda --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/tasks/T03-SUMMARY.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 09d5681..2170069 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { }>} /> }>} /> }>} /> + }>} /> {/* Info routes */} }>} /> diff --git a/frontend/src/components/AdminDropdown.tsx b/frontend/src/components/AdminDropdown.tsx index 16d2cb8..be43a49 100644 --- a/frontend/src/components/AdminDropdown.tsx +++ b/frontend/src/components/AdminDropdown.tsx @@ -118,6 +118,14 @@ export default function AdminDropdown() { > Users + setOpen(false)} + > + Audit Log + )} diff --git a/frontend/src/pages/AdminAuditLog.module.css b/frontend/src/pages/AdminAuditLog.module.css new file mode 100644 index 0000000..25adbc8 --- /dev/null +++ b/frontend/src/pages/AdminAuditLog.module.css @@ -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; + } +} diff --git a/frontend/src/pages/AdminAuditLog.tsx b/frontend/src/pages/AdminAuditLog.tsx new file mode 100644 index 0000000..26faf9e --- /dev/null +++ b/frontend/src/pages/AdminAuditLog.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

Audit Log

+

Loading audit log…

+
+ ); + } + + if (error) { + return ( +
+

Audit Log

+

{error}

+
+ ); + } + + return ( +
+

Audit Log

+ {entries.length === 0 ? ( +

No impersonation log entries found.

+ ) : ( + + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + ))} + +
Date/TimeAdminTarget UserActionWrite ModeIP Address
{new Date(entry.created_at).toLocaleString()}{entry.admin_name}{entry.target_name} + + {entry.action} + + + + {entry.write_mode ? "Yes" : "No"} + + {entry.ip_address ?? "—"}
+ )} +
+ + Page {page} + +
+
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index c248a2a..6fd5928 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file