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
ab9dd2aa1b
commit
4969935c76
12 changed files with 405 additions and 26 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
24
.gsd/milestones/M021/slices/S07/tasks/T01-VERIFY.json
Normal file
24
.gsd/milestones/M021/slices/S07/tasks/T01-VERIFY.json
Normal file
|
|
@ -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
|
||||
}
|
||||
89
.gsd/milestones/M021/slices/S07/tasks/T02-SUMMARY.md
Normal file
89
.gsd/milestones/M021/slices/S07/tasks/T02-SUMMARY.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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