diff --git a/.gsd/milestones/M021/slices/S07/S07-PLAN.md b/.gsd/milestones/M021/slices/S07/S07-PLAN.md index bb509e7..fd6fdd3 100644 --- a/.gsd/milestones/M021/slices/S07/S07-PLAN.md +++ b/.gsd/milestones/M021/slices/S07/S07-PLAN.md @@ -37,7 +37,7 @@ - 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. +- [x] **T02: Added ConfirmModal component, Edit As button with write-mode confirmation, red/amber banner mode switching, and write_mode API param support** — Add a confirmation modal for write-mode impersonation and update the banner to reflect mode. ## Steps diff --git a/.gsd/milestones/M021/slices/S07/tasks/T01-VERIFY.json b/.gsd/milestones/M021/slices/S07/tasks/T01-VERIFY.json new file mode 100644 index 0000000..63e0ee3 --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/tasks/T01-VERIFY.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M021/S07/T01", + "timestamp": 1775283844919, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd backend", + "exitCode": 0, + "durationMs": 7, + "verdict": "pass" + }, + { + "command": "python -m pytest tests/test_impersonation.py -v", + "exitCode": 4, + "durationMs": 212, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M021/slices/S07/tasks/T02-SUMMARY.md b/.gsd/milestones/M021/slices/S07/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..365b70d --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/tasks/T02-SUMMARY.md @@ -0,0 +1,89 @@ +--- +id: T02 +parent: S07 +milestone: M021 +provides: [] +requires: [] +affects: [] +key_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/pages/AdminUsers.module.css", "frontend/src/components/ImpersonationBanner.tsx", "frontend/src/components/ImpersonationBanner.module.css"] +key_decisions: ["ConfirmModal uses data-variant attribute for confirm button color instead of separate CSS classes", "isWriteMode state reset on exitImpersonation rather than derived from token"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Ran cd frontend && npm run build. All TypeScript errors in output are pre-existing in ChapterReview.tsx (confirmed by stash/unstash comparison). Zero errors from any T02 files. ConfirmModal, auth.ts, AuthContext, AdminUsers, and ImpersonationBanner all compile cleanly." +completed_at: 2026-04-04T06:27:34.416Z +blocker_discovered: false +--- + +# T02: Added ConfirmModal component, Edit As button with write-mode confirmation, red/amber banner mode switching, and write_mode API param support + +> Added ConfirmModal component, Edit As button with write-mode confirmation, red/amber banner mode switching, and write_mode API param support + +## What Happened +--- +id: T02 +parent: S07 +milestone: M021 +key_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/pages/AdminUsers.module.css + - frontend/src/components/ImpersonationBanner.tsx + - frontend/src/components/ImpersonationBanner.module.css +key_decisions: + - ConfirmModal uses data-variant attribute for confirm button color instead of separate CSS classes + - isWriteMode state reset on exitImpersonation rather than derived from token +duration: "" +verification_result: passed +completed_at: 2026-04-04T06:27:34.416Z +blocker_discovered: false +--- + +# T02: Added ConfirmModal component, Edit As button with write-mode confirmation, red/amber banner mode switching, and write_mode API param support + +**Added ConfirmModal component, Edit As button with write-mode confirmation, red/amber banner mode switching, and write_mode API param support** + +## What Happened + +Created a reusable ConfirmModal component with backdrop, Escape/backdrop-click dismiss, and warning/danger variants. Updated impersonateUser() API function to accept optional writeMode param — when true, sends { write_mode: true } as JSON body. Added ImpersonationLogEntry interface and fetchImpersonationLog() function for T03's audit log page. Extended AuthContext with isWriteMode state that gets set on startImpersonation(userId, true) and cleared on exitImpersonation. Updated AdminUsers page to show both View As (read-only, no modal) and Edit As (opens ConfirmModal with danger variant) buttons. Updated ImpersonationBanner to show red background with pencil icon Editing as in write mode vs amber eye icon Viewing as in read mode, and toggle body.impersonating-write class. + +## Verification + +Ran cd frontend && npm run build. All TypeScript errors in output are pre-existing in ChapterReview.tsx (confirmed by stash/unstash comparison). Zero errors from any T02 files. ConfirmModal, auth.ts, AuthContext, AdminUsers, and ImpersonationBanner all compile cleanly. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build 2>&1 | tail -5` | 2 | ✅ pass (pre-existing errors only, none from T02 files) | 8000ms | + + +## Deviations + +None. + +## Known Issues + +Pre-existing TypeScript errors in ChapterReview.tsx cause npm run build to exit non-zero. Slice-level verification command tests/test_impersonation.py path should be backend/tests/test_impersonation.py. + +## Files Created/Modified + +- `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/pages/AdminUsers.module.css` +- `frontend/src/components/ImpersonationBanner.tsx` +- `frontend/src/components/ImpersonationBanner.module.css` + + +## Deviations +None. + +## Known Issues +Pre-existing TypeScript errors in ChapterReview.tsx cause npm run build to exit non-zero. Slice-level verification command tests/test_impersonation.py path should be backend/tests/test_impersonation.py. diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index f5e9463..beb6bde 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -93,16 +93,42 @@ export async function fetchUsers(token: string): Promise { }); } +export interface ImpersonationLogEntry { + id: number; + admin_name: string; + target_name: string; + action: string; + write_mode: boolean; + ip_address: string | null; + created_at: string; +} + export async function impersonateUser( token: string, userId: string, + writeMode?: boolean, ): Promise { + const opts: RequestInit = { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }; + if (writeMode) { + (opts.headers as Record)["Content-Type"] = "application/json"; + opts.body = JSON.stringify({ write_mode: true }); + } return request( `${BASE}/admin/impersonate/${userId}`, - { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }, + opts, + ); +} + +export async function fetchImpersonationLog( + token: string, + page: number = 1, +): Promise { + return request( + `${BASE}/admin/impersonation-log?page=${page}`, + { headers: { Authorization: `Bearer ${token}` } }, ); } diff --git a/frontend/src/components/ConfirmModal.module.css b/frontend/src/components/ConfirmModal.module.css new file mode 100644 index 0000000..61e9b90 --- /dev/null +++ b/frontend/src/components/ConfirmModal.module.css @@ -0,0 +1,93 @@ +.backdrop { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + animation: fadeIn 150ms ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.card { + background: #1e1e2e; + border: 1px solid #333; + border-radius: 12px; + padding: 1.5rem; + max-width: 440px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + animation: slideUp 150ms ease-out; +} + +@keyframes slideUp { + from { transform: translateY(12px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.title { + margin: 0 0 0.75rem; + font-size: 1.1rem; + font-weight: 700; + color: #f0f0f0; +} + +.message { + margin: 0 0 1.25rem; + font-size: 0.9rem; + color: #aaa; + line-height: 1.5; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.cancelBtn { + padding: 0.5rem 1rem; + border: 1px solid #555; + border-radius: 6px; + background: transparent; + color: #ccc; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: background 150ms, border-color 150ms; +} + +.cancelBtn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: #888; +} + +.confirmBtn { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: background 150ms, filter 150ms; +} + +.confirmBtn:hover { + filter: brightness(1.15); +} + +.confirmBtn[data-variant="danger"] { + background: #dc2626; + color: #fff; +} + +.confirmBtn[data-variant="warning"] { + background: #b45309; + color: #fff; +} diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..5b4ef3c --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -0,0 +1,66 @@ +import { useEffect, useCallback } from "react"; +import styles from "./ConfirmModal.module.css"; + +interface ConfirmModalProps { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel: () => void; + variant?: "warning" | "danger"; +} + +export default function ConfirmModal({ + open, + title, + message, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + onConfirm, + onCancel, + variant = "warning", +}: ConfirmModalProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }, + [onCancel], + ); + + useEffect(() => { + if (!open) return; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, handleKeyDown]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +

{title}

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ImpersonationBanner.module.css b/frontend/src/components/ImpersonationBanner.module.css index ac8187f..d3ede8e 100644 --- a/frontend/src/components/ImpersonationBanner.module.css +++ b/frontend/src/components/ImpersonationBanner.module.css @@ -47,3 +47,8 @@ :global(body.impersonating) { padding-top: 40px; } + +/* Write-mode red banner */ +.writeMode { + background: #dc2626; +} diff --git a/frontend/src/components/ImpersonationBanner.tsx b/frontend/src/components/ImpersonationBanner.tsx index f44c095..b8393b5 100644 --- a/frontend/src/components/ImpersonationBanner.tsx +++ b/frontend/src/components/ImpersonationBanner.tsx @@ -3,30 +3,42 @@ import { useAuth } from "../context/AuthContext"; import styles from "./ImpersonationBanner.module.css"; /** - * Fixed amber banner shown when an admin is impersonating a creator. - * Adds body.impersonating class to push page content down. + * Fixed banner shown when an admin is impersonating a creator. + * Red in write mode, amber in read-only mode. + * Adds body.impersonating (and body.impersonating-write) to push page content down. */ export default function ImpersonationBanner() { - const { isImpersonating, user, exitImpersonation } = useAuth(); + const { isImpersonating, isWriteMode, user, exitImpersonation } = useAuth(); useEffect(() => { if (isImpersonating) { document.body.classList.add("impersonating"); + if (isWriteMode) { + document.body.classList.add("impersonating-write"); + } else { + document.body.classList.remove("impersonating-write"); + } } else { - document.body.classList.remove("impersonating"); + document.body.classList.remove("impersonating", "impersonating-write"); } return () => { - document.body.classList.remove("impersonating"); + document.body.classList.remove("impersonating", "impersonating-write"); }; - }, [isImpersonating]); + }, [isImpersonating, isWriteMode]); if (!isImpersonating) return null; return ( -
+
- - Viewing as {user?.display_name ?? "Unknown"} + + {isWriteMode ? "Editing" : "Viewing"} as{" "} + {user?.display_name ?? "Unknown"} +
+ + +
)} @@ -91,6 +113,16 @@ export default function AdminUsers() { )} + setEditTarget(null)} + />
); } diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index e3e1d82..c248a2a 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/CategoryIcons.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/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"],"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/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