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:
parent
5a39850a35
commit
944e152c6f
9 changed files with 291 additions and 25 deletions
|
|
@ -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(
|
||||
token: string,
|
||||
userId: string,
|
||||
writeMode?: boolean,
|
||||
): 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>(
|
||||
`${BASE}/admin/impersonate/${userId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchImpersonationLog(
|
||||
token: string,
|
||||
page: number = 1,
|
||||
): Promise<ImpersonationLogEntry[]> {
|
||||
return request<ImpersonationLogEntry[]>(
|
||||
`${BASE}/admin/impersonation-log?page=${page}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
93
frontend/src/components/ConfirmModal.module.css
Normal file
93
frontend/src/components/ConfirmModal.module.css
Normal 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;
|
||||
}
|
||||
66
frontend/src/components/ConfirmModal.tsx
Normal file
66
frontend/src/components/ConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -47,3 +47,8 @@
|
|||
:global(body.impersonating) {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
/* Write-mode red banner */
|
||||
.writeMode {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={styles.banner} role="alert">
|
||||
<div
|
||||
className={`${styles.banner} ${isWriteMode ? styles.writeMode : ""}`}
|
||||
role="alert"
|
||||
>
|
||||
<span className={styles.text}>
|
||||
<span className={styles.icon} aria-hidden="true">👁</span>
|
||||
Viewing as <strong>{user?.display_name ?? "Unknown"}</strong>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{isWriteMode ? "✏️" : "👁"}
|
||||
</span>
|
||||
{isWriteMode ? "Editing" : "Viewing"} as{" "}
|
||||
<strong>{user?.display_name ?? "Unknown"}</strong>
|
||||
</span>
|
||||
<button className={styles.exitBtn} onClick={exitImpersonation}>
|
||||
Exit
|
||||
|
|
|
|||
|
|
@ -25,11 +25,12 @@ interface AuthContextValue {
|
|||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isImpersonating: boolean;
|
||||
isWriteMode: boolean;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (data: RegisterRequest) => Promise<UserResponse>;
|
||||
logout: () => void;
|
||||
startImpersonation: (userId: string) => Promise<void>;
|
||||
startImpersonation: (userId: string, writeMode?: boolean) => Promise<void>;
|
||||
exitImpersonation: () => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(!!token);
|
||||
const [isWriteMode, setIsWriteMode] = useState(false);
|
||||
|
||||
// Rehydrate session from stored token on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -89,13 +91,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const startImpersonation = useCallback(async (userId: string) => {
|
||||
const startImpersonation = useCallback(async (userId: string, writeMode?: boolean) => {
|
||||
if (!token) return;
|
||||
// Save admin token so we can restore it later
|
||||
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);
|
||||
setToken(resp.access_token);
|
||||
setIsWriteMode(!!writeMode);
|
||||
const me = await authGetMe(resp.access_token);
|
||||
setUser(me);
|
||||
}, [token]);
|
||||
|
|
@ -112,6 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
// Restore admin token
|
||||
const adminToken = sessionStorage.getItem(ADMIN_TOKEN_KEY);
|
||||
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
setIsWriteMode(false);
|
||||
if (adminToken) {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, adminToken);
|
||||
setToken(adminToken);
|
||||
|
|
@ -130,6 +134,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
token,
|
||||
isAuthenticated: !!user,
|
||||
isImpersonating: !!user?.impersonating,
|
||||
isWriteMode,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,33 @@
|
|||
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,
|
||||
.error,
|
||||
.empty {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||
import { useAuth } from "../context/AuthContext";
|
||||
import { fetchUsers, type UserListItem } from "../api";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import styles from "./AdminUsers.module.css";
|
||||
|
||||
export default function AdminUsers() {
|
||||
|
|
@ -11,6 +12,7 @@ export default function AdminUsers() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [impersonating, setImpersonating] = useState<string | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<UserListItem | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
|
@ -25,13 +27,24 @@ export default function AdminUsers() {
|
|||
setImpersonating(userId);
|
||||
try {
|
||||
await startImpersonation(userId);
|
||||
// Navigation will happen via auth context update
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to start impersonation");
|
||||
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) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
|
|
@ -77,13 +90,22 @@ export default function AdminUsers() {
|
|||
</td>
|
||||
<td>
|
||||
{u.role === "creator" && u.id !== currentUser?.id && (
|
||||
<button
|
||||
className={styles.viewAsBtn}
|
||||
disabled={impersonating === u.id}
|
||||
onClick={() => handleViewAs(u.id)}
|
||||
>
|
||||
{impersonating === u.id ? "Switching…" : "View As"}
|
||||
</button>
|
||||
<div className={styles.actionBtns}>
|
||||
<button
|
||||
className={styles.viewAsBtn}
|
||||
disabled={impersonating === u.id}
|
||||
onClick={() => handleViewAs(u.id)}
|
||||
>
|
||||
{impersonating === u.id ? "Switching…" : "View As"}
|
||||
</button>
|
||||
<button
|
||||
className={styles.editAsBtn}
|
||||
disabled={impersonating === u.id}
|
||||
onClick={() => setEditTarget(u)}
|
||||
>
|
||||
Edit As
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -91,6 +113,16 @@ export default function AdminUsers() {
|
|||
</tbody>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
Loading…
Add table
Reference in a new issue