From 944e152c6fc8e53ea051e3b17a5272d1aea42b2e Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 06:27:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20ConfirmModal=20component,=20Edi?= =?UTF-8?q?t=20As=20button=20with=20write-mode=20conf=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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" GSD-Task: S07/T02 --- frontend/src/api/auth.ts | 34 ++++++- .../src/components/ConfirmModal.module.css | 93 +++++++++++++++++++ frontend/src/components/ConfirmModal.tsx | 66 +++++++++++++ .../components/ImpersonationBanner.module.css | 5 + .../src/components/ImpersonationBanner.tsx | 30 ++++-- frontend/src/context/AuthContext.tsx | 11 ++- frontend/src/pages/AdminUsers.module.css | 27 ++++++ frontend/src/pages/AdminUsers.tsx | 48 ++++++++-- frontend/tsconfig.app.tsbuildinfo | 2 +- 9 files changed, 291 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/ConfirmModal.module.css create mode 100644 frontend/src/components/ConfirmModal.tsx 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