- "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
128 lines
4 KiB
TypeScript
128 lines
4 KiB
TypeScript
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() {
|
|
useDocumentTitle("Users — Admin");
|
|
const { token, startImpersonation, user: currentUser } = useAuth();
|
|
const [users, setUsers] = useState<UserListItem[]>([]);
|
|
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;
|
|
setLoading(true);
|
|
fetchUsers(token)
|
|
.then(setUsers)
|
|
.catch((e) => setError(e.message || "Failed to load users"))
|
|
.finally(() => setLoading(false));
|
|
}, [token]);
|
|
|
|
async function handleViewAs(userId: string) {
|
|
setImpersonating(userId);
|
|
try {
|
|
await startImpersonation(userId);
|
|
} 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}>
|
|
<h1 className={styles.title}>Users</h1>
|
|
<p className={styles.loading}>Loading users…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className={styles.page}>
|
|
<h1 className={styles.title}>Users</h1>
|
|
<p className={styles.error}>{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<h1 className={styles.title}>Users</h1>
|
|
{users.length === 0 ? (
|
|
<p className={styles.empty}>No users found.</p>
|
|
) : (
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Role</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u) => (
|
|
<tr key={u.id}>
|
|
<td>{u.display_name}</td>
|
|
<td>{u.email}</td>
|
|
<td>
|
|
<span className={styles.roleBadge} data-role={u.role}>
|
|
{u.role}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{u.role === "creator" && u.id !== currentUser?.id && (
|
|
<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>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|