feat: Added ConfirmModal component, Edit As button with write-mode conf…

- "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
This commit is contained in:
jlightner 2026-04-04 06:27:38 +00:00
parent 5a39850a35
commit 944e152c6f
9 changed files with 291 additions and 25 deletions

View file

@ -93,16 +93,42 @@ export async function fetchUsers(token: string): Promise<UserListItem[]> {
}); });
} }
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( export async function impersonateUser(
token: string, token: string,
userId: string, userId: string,
writeMode?: boolean,
): Promise<ImpersonateResponse> { ): Promise<ImpersonateResponse> {
const opts: RequestInit = {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
};
if (writeMode) {
(opts.headers as Record<string, string>)["Content-Type"] = "application/json";
opts.body = JSON.stringify({ write_mode: true });
}
return request<ImpersonateResponse>( return request<ImpersonateResponse>(
`${BASE}/admin/impersonate/${userId}`, `${BASE}/admin/impersonate/${userId}`,
{ opts,
method: "POST", );
headers: { Authorization: `Bearer ${token}` }, }
},
export async function fetchImpersonationLog(
token: string,
page: number = 1,
): Promise<ImpersonationLogEntry[]> {
return request<ImpersonationLogEntry[]>(
`${BASE}/admin/impersonation-log?page=${page}`,
{ headers: { Authorization: `Bearer ${token}` } },
); );
} }

View file

@ -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;
}

View file

@ -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 (
<div
className={styles.backdrop}
onClick={onCancel}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className={styles.card} onClick={(e) => e.stopPropagation()}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
<button className={styles.cancelBtn} onClick={onCancel}>
{cancelLabel}
</button>
<button
className={styles.confirmBtn}
data-variant={variant}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View file

@ -47,3 +47,8 @@
:global(body.impersonating) { :global(body.impersonating) {
padding-top: 40px; padding-top: 40px;
} }
/* Write-mode red banner */
.writeMode {
background: #dc2626;
}

View file

@ -3,30 +3,42 @@ import { useAuth } from "../context/AuthContext";
import styles from "./ImpersonationBanner.module.css"; import styles from "./ImpersonationBanner.module.css";
/** /**
* Fixed amber banner shown when an admin is impersonating a creator. * Fixed banner shown when an admin is impersonating a creator.
* Adds body.impersonating class to push page content down. * 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() { export default function ImpersonationBanner() {
const { isImpersonating, user, exitImpersonation } = useAuth(); const { isImpersonating, isWriteMode, user, exitImpersonation } = useAuth();
useEffect(() => { useEffect(() => {
if (isImpersonating) { if (isImpersonating) {
document.body.classList.add("impersonating"); document.body.classList.add("impersonating");
if (isWriteMode) {
document.body.classList.add("impersonating-write");
} else {
document.body.classList.remove("impersonating-write");
}
} else { } else {
document.body.classList.remove("impersonating"); document.body.classList.remove("impersonating", "impersonating-write");
} }
return () => { return () => {
document.body.classList.remove("impersonating"); document.body.classList.remove("impersonating", "impersonating-write");
}; };
}, [isImpersonating]); }, [isImpersonating, isWriteMode]);
if (!isImpersonating) return null; if (!isImpersonating) return null;
return ( return (
<div className={styles.banner} role="alert"> <div
className={`${styles.banner} ${isWriteMode ? styles.writeMode : ""}`}
role="alert"
>
<span className={styles.text}> <span className={styles.text}>
<span className={styles.icon} aria-hidden="true">👁</span> <span className={styles.icon} aria-hidden="true">
Viewing as <strong>{user?.display_name ?? "Unknown"}</strong> {isWriteMode ? "✏️" : "👁"}
</span>
{isWriteMode ? "Editing" : "Viewing"} as{" "}
<strong>{user?.display_name ?? "Unknown"}</strong>
</span> </span>
<button className={styles.exitBtn} onClick={exitImpersonation}> <button className={styles.exitBtn} onClick={exitImpersonation}>
Exit Exit

View file

@ -25,11 +25,12 @@ interface AuthContextValue {
token: string | null; token: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isImpersonating: boolean; isImpersonating: boolean;
isWriteMode: boolean;
loading: boolean; loading: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (data: RegisterRequest) => Promise<UserResponse>; register: (data: RegisterRequest) => Promise<UserResponse>;
logout: () => void; logout: () => void;
startImpersonation: (userId: string) => Promise<void>; startImpersonation: (userId: string, writeMode?: boolean) => Promise<void>;
exitImpersonation: () => Promise<void>; exitImpersonation: () => Promise<void>;
} }
@ -45,6 +46,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}); });
const [loading, setLoading] = useState(!!token); const [loading, setLoading] = useState(!!token);
const [isWriteMode, setIsWriteMode] = useState(false);
// Rehydrate session from stored token on mount // Rehydrate session from stored token on mount
useEffect(() => { useEffect(() => {
@ -89,13 +91,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
}, []); }, []);
const startImpersonation = useCallback(async (userId: string) => { const startImpersonation = useCallback(async (userId: string, writeMode?: boolean) => {
if (!token) return; if (!token) return;
// Save admin token so we can restore it later // Save admin token so we can restore it later
sessionStorage.setItem(ADMIN_TOKEN_KEY, token); sessionStorage.setItem(ADMIN_TOKEN_KEY, token);
const resp = await impersonateUser(token, userId); const resp = await impersonateUser(token, userId, writeMode);
localStorage.setItem(AUTH_TOKEN_KEY, resp.access_token); localStorage.setItem(AUTH_TOKEN_KEY, resp.access_token);
setToken(resp.access_token); setToken(resp.access_token);
setIsWriteMode(!!writeMode);
const me = await authGetMe(resp.access_token); const me = await authGetMe(resp.access_token);
setUser(me); setUser(me);
}, [token]); }, [token]);
@ -112,6 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Restore admin token // Restore admin token
const adminToken = sessionStorage.getItem(ADMIN_TOKEN_KEY); const adminToken = sessionStorage.getItem(ADMIN_TOKEN_KEY);
sessionStorage.removeItem(ADMIN_TOKEN_KEY); sessionStorage.removeItem(ADMIN_TOKEN_KEY);
setIsWriteMode(false);
if (adminToken) { if (adminToken) {
localStorage.setItem(AUTH_TOKEN_KEY, adminToken); localStorage.setItem(AUTH_TOKEN_KEY, adminToken);
setToken(adminToken); setToken(adminToken);
@ -130,6 +134,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
token, token,
isAuthenticated: !!user, isAuthenticated: !!user,
isImpersonating: !!user?.impersonating, isImpersonating: !!user?.impersonating,
isWriteMode,
loading, loading,
login, login,
register, register,

View file

@ -76,6 +76,33 @@
cursor: not-allowed; cursor: not-allowed;
} }
.actionBtns {
display: flex;
gap: 0.5rem;
}
.editAsBtn {
padding: 0.3rem 0.6rem;
border: 1px solid #dc2626;
border-radius: 4px;
background: transparent;
color: #dc2626;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 150ms, color 150ms;
}
.editAsBtn:hover {
background: #dc2626;
color: #fff;
}
.editAsBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.loading, .loading,
.error, .error,
.empty { .empty {

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { fetchUsers, type UserListItem } from "../api"; import { fetchUsers, type UserListItem } from "../api";
import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { useDocumentTitle } from "../hooks/useDocumentTitle";
import ConfirmModal from "../components/ConfirmModal";
import styles from "./AdminUsers.module.css"; import styles from "./AdminUsers.module.css";
export default function AdminUsers() { export default function AdminUsers() {
@ -11,6 +12,7 @@ export default function AdminUsers() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [impersonating, setImpersonating] = useState<string | null>(null); const [impersonating, setImpersonating] = useState<string | null>(null);
const [editTarget, setEditTarget] = useState<UserListItem | null>(null);
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
@ -25,13 +27,24 @@ export default function AdminUsers() {
setImpersonating(userId); setImpersonating(userId);
try { try {
await startImpersonation(userId); await startImpersonation(userId);
// Navigation will happen via auth context update
} catch (e: any) { } catch (e: any) {
setError(e.message || "Failed to start impersonation"); setError(e.message || "Failed to start impersonation");
setImpersonating(null); setImpersonating(null);
} }
} }
async function handleEditAsConfirm() {
if (!editTarget) return;
setEditTarget(null);
setImpersonating(editTarget.id);
try {
await startImpersonation(editTarget.id, true);
} catch (e: any) {
setError(e.message || "Failed to start write-mode impersonation");
setImpersonating(null);
}
}
if (loading) { if (loading) {
return ( return (
<div className={styles.page}> <div className={styles.page}>
@ -77,13 +90,22 @@ export default function AdminUsers() {
</td> </td>
<td> <td>
{u.role === "creator" && u.id !== currentUser?.id && ( {u.role === "creator" && u.id !== currentUser?.id && (
<button <div className={styles.actionBtns}>
className={styles.viewAsBtn} <button
disabled={impersonating === u.id} className={styles.viewAsBtn}
onClick={() => handleViewAs(u.id)} disabled={impersonating === u.id}
> onClick={() => handleViewAs(u.id)}
{impersonating === u.id ? "Switching…" : "View As"} >
</button> {impersonating === u.id ? "Switching…" : "View As"}
</button>
<button
className={styles.editAsBtn}
disabled={impersonating === u.id}
onClick={() => setEditTarget(u)}
>
Edit As
</button>
</div>
)} )}
</td> </td>
</tr> </tr>
@ -91,6 +113,16 @@ export default function AdminUsers() {
</tbody> </tbody>
</table> </table>
)} )}
<ConfirmModal
open={!!editTarget}
title="Enable Write Mode"
message={`You are about to edit as ${editTarget?.display_name ?? "this user"}. Changes will be attributed to this creator and logged. Continue?`}
confirmLabel="Enable Write Mode"
cancelLabel="Cancel"
variant="danger"
onConfirm={handleEditAsConfirm}
onCancel={() => setEditTarget(null)}
/>
</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/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"} {"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"}