Add user management (CRUD + admin toggle), make Settings available to all users

- New /admin/users page: create/delete users, toggle admin role, guarded against
  self-demotion/self-deletion
- Backend: GET/POST/PATCH/DELETE /api/admin/users, admin-only
- Settings moved from /admin/settings to /settings (all logged-in users); the
  password-change section shows for everyone, integration/API-key sections only
  render (and only fetch) for admins, since those endpoints stay admin-gated
- Navbar: Settings link now visible to all users; added Users nav link for admins
This commit is contained in:
Myron Blair
2026-07-26 19:21:50 -05:00
parent 0f6f03509a
commit 5b191da471
7 changed files with 237 additions and 13 deletions
+3 -1
View File
@@ -24,6 +24,7 @@ import ShowDetail from "./pages/ShowDetail";
import EpisodePlayer from "./pages/EpisodePlayer";
import SonarrImport from "./pages/SonarrImport";
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
import Users from "./pages/Users";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
@@ -89,9 +90,10 @@ function App() {
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
<Route path="/settings" element={<ProfileGate><Settings /></ProfileGate>} />
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
<Route path="/admin/users" element={<AdminGate><Users /></AdminGate>} />
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
+7 -8
View File
@@ -39,6 +39,7 @@ export const Navbar = () => {
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">Shelf</NavLink>
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Wishlist</NavLink>
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
{user.is_admin && <NavLink to="/admin/users" className={linkClass} data-testid="nav-users">Users</NavLink>}
</nav>
)}
</div>
@@ -49,15 +50,13 @@ export const Navbar = () => {
<Search size={18} strokeWidth={1.5} />
</button>
{user.is_admin && (
<>
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
<Upload size={14} strokeWidth={1.5} /> Upload
</button>
<button onClick={() => nav("/admin/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</button>
</>
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
<Upload size={14} strokeWidth={1.5} /> Upload
</button>
)}
<button onClick={() => nav("/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</button>
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
<button onClick={switchProfile} className="flex items-center gap-2 group" data-testid="nav-switch-profile" title="Switch profile">
{active && (
+4 -1
View File
@@ -88,9 +88,12 @@ export default function Admin() {
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link>
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
<Link to="/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</Link>
<Link to="/admin/users" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-users-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Users
</Link>
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
Review Requests
</Link>
+9 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, createContext, useContext } from "react";
import api from "../lib/api";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
import { Eye, EyeOff, ListTodo, Link2, Unlink } from "lucide-react";
@@ -40,6 +41,8 @@ const Field = ({ label, k, type = "text", placeholder, mask }) => {
};
export default function Settings() {
const { user } = useAuth();
const isAdmin = !!user?.is_admin;
const [s, setS] = useState({
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
sonarr_url: "", sonarr_api_key: "",
@@ -56,6 +59,7 @@ export default function Settings() {
const [pwSaving, setPwSaving] = useState(false);
const load = async () => {
if (!isAdmin) return;
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]);
setS({
tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "",
@@ -66,7 +70,7 @@ export default function Settings() {
});
setInfo(data); setTraktStatus(ts);
};
useEffect(() => { load(); }, []);
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const save = async (e) => {
e.preventDefault(); setSaving(true);
@@ -123,11 +127,11 @@ export default function Settings() {
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
<div className="px-6 md:px-12 max-w-3xl mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">{isAdmin ? "Admin" : "Account"}</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
<form onSubmit={changePassword} className="mt-12 space-y-4 pb-10 border-b border-[#222]" data-testid="change-password-form">
<h2 className="font-display text-2xl font-bold text-white">Admin Password</h2>
<h2 className="font-display text-2xl font-bold text-white">Password</h2>
<p className="text-sm text-[#8A8A8A]">Requires your current password. Changing it locks out anyone who only had the old one.</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Current password</span>
@@ -169,6 +173,7 @@ export default function Settings() {
</button>
</form>
{isAdmin && (
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
{/* TMDB */}
<section>
@@ -267,6 +272,7 @@ export default function Settings() {
{saving ? "Saving…" : "Save Settings"}
</button>
</form>
)}
</div>
{/* Trakt device-code modal */}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
import { Trash2, UserPlus, Eye, EyeOff } from "lucide-react";
export default function Users() {
const { user: me } = useAuth();
const [users, setUsers] = useState([]);
const [showNew, setShowNew] = useState(false);
const [form, setForm] = useState({ email: "", password: "", name: "", is_admin: false });
const [showPw, setShowPw] = useState(false);
const [saving, setSaving] = useState(false);
const load = async () => {
const { data } = await api.get("/admin/users");
setUsers(data);
};
useEffect(() => { load(); }, []);
const createUser = async (e) => {
e.preventDefault();
setSaving(true);
try {
await api.post("/admin/users", form);
toast.success("User created");
setForm({ email: "", password: "", name: "", is_admin: false });
setShowNew(false);
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not create user");
} finally {
setSaving(false);
}
};
const toggleAdmin = async (u) => {
try {
await api.patch(`/admin/users/${u.id}`, { is_admin: !u.is_admin });
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not update user");
}
};
const removeUser = async (u) => {
if (!window.confirm(`Delete user ${u.email}? This also removes their profiles/watchlist.`)) return;
try {
await api.delete(`/admin/users/${u.id}`);
toast.success("User deleted");
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not delete user");
}
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="users-page">
<div className="px-6 md:px-12 max-w-4xl mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Users</h1>
<button
onClick={() => setShowNew(!showNew)}
className="mt-8 flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]"
data-testid="new-user-button"
>
<UserPlus size={14} strokeWidth={1.5} /> {showNew ? "Cancel" : "New User"}
</button>
{showNew && (
<form onSubmit={createUser} className="mt-6 border border-[#222] p-6 space-y-4" data-testid="new-user-form">
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3" />
</label>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Email</span>
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3" />
</label>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Password (min 8 chars)</span>
<div className="relative mt-2">
<input type={showPw ? "text" : "password"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12" />
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{showPw ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
</div>
</label>
<label className="flex items-center gap-3">
<input type="checkbox" checked={form.is_admin} onChange={(e) => setForm({ ...form, is_admin: e.target.checked })} />
<span className="text-sm text-[#8A8A8A]">Admin access</span>
</label>
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]">
{saving ? "Creating…" : "Create User"}
</button>
</form>
)}
<div className="mt-10 border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-4">Name</span>
<span className="col-span-4">Email</span>
<span className="col-span-2">Role</span>
<span className="col-span-2 text-right">Actions</span>
</div>
{users.map((u) => (
<div key={u.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`user-row-${u.id}`}>
<span className="col-span-4 text-white truncate">{u.name}</span>
<span className="col-span-4 text-[#8A8A8A] text-sm truncate">{u.email}</span>
<div className="col-span-2">
<button
onClick={() => toggleAdmin(u)}
disabled={u.id === me?.id && u.is_admin}
title={u.id === me?.id && u.is_admin ? "Can't remove your own admin access" : "Toggle role"}
className={`text-[10px] uppercase tracking-[0.2em] px-2 py-1 border disabled:opacity-40 disabled:cursor-not-allowed ${u.is_admin ? "border-[#D9381E] text-[#D9381E]" : "border-[#222] text-[#8A8A8A] hover:border-white hover:text-white"}`}
data-testid={`toggle-admin-${u.id}`}
>
{u.is_admin ? "Admin" : "User"}
</button>
</div>
<div className="col-span-2 flex justify-end">
<button
onClick={() => removeUser(u)}
disabled={u.id === me?.id}
title={u.id === me?.id ? "Can't delete your own account" : "Delete"}
className="text-[#8A8A8A] hover:text-[#fca5a5] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
data-testid={`delete-user-${u.id}`}
>
<Trash2 size={16} strokeWidth={1.5} />
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}