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
+13
View File
@@ -34,6 +34,19 @@ class UserPublic(BaseModel):
created_at: str
class AdminUserCreate(BaseModel):
email: str
password: str
name: str
is_admin: bool = False
class AdminUserUpdate(BaseModel):
name: Optional[str] = None
is_admin: Optional[bool] = None
password: Optional[str] = None
# ---------- Profiles ("Who's Watching") ----------
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
+59
View File
@@ -16,6 +16,7 @@ from motor.motor_asyncio import AsyncIOMotorClient
from models import (
UserCreate, UserLogin, UserPublic, TokenResponse, PasswordChange,
AdminUserCreate, AdminUserUpdate,
Profile, ProfileCreate, ProfileUpdate, RATING_ORDER,
Movie, MovieCreate, MovieUpdate,
Subtitle,
@@ -249,6 +250,64 @@ async def change_password(payload: PasswordChange, user: dict = Depends(get_curr
return {"ok": True}
# ============ USERS (admin) ============
@api.get("/admin/users", response_model=List[UserPublic])
async def admin_list_users(user: dict = Depends(require_admin)):
docs = await db.users.find({}, {"_id": 0, "password_hash": 0}).sort("created_at", 1).to_list(500)
return docs
@api.post("/admin/users", response_model=UserPublic)
async def admin_create_user(payload: AdminUserCreate, user: dict = Depends(require_admin)):
if await db.users.find_one({"email": payload.email.lower()}):
raise HTTPException(status_code=400, detail="Email already registered")
new_user = {
"id": str(uuid.uuid4()),
"email": payload.email.lower(),
"name": payload.name,
"password_hash": hash_password(payload.password),
"is_admin": payload.is_admin,
"created_at": datetime.now(timezone.utc).isoformat(),
}
await db.users.insert_one(new_user)
await db.profiles.insert_one(Profile(user_id=new_user["id"], name=new_user["name"]).model_dump())
return _user_public(new_user)
@api.patch("/admin/users/{user_id}", response_model=UserPublic)
async def admin_update_user(user_id: str, payload: AdminUserUpdate, user: dict = Depends(require_admin)):
target = await db.users.find_one({"id": user_id})
if not target:
raise HTTPException(status_code=404, detail="User not found")
updates = {}
if payload.name is not None:
updates["name"] = payload.name
if payload.is_admin is not None:
if user_id == user["id"] and not payload.is_admin:
raise HTTPException(status_code=400, detail="Cannot remove your own admin access")
updates["is_admin"] = payload.is_admin
if payload.password:
if len(payload.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
updates["password_hash"] = hash_password(payload.password)
if updates:
await db.users.update_one({"id": user_id}, {"$set": updates})
target = await db.users.find_one({"id": user_id})
return _user_public(target)
@api.delete("/admin/users/{user_id}")
async def admin_delete_user(user_id: str, user: dict = Depends(require_admin)):
if user_id == user["id"]:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
target = await db.users.find_one({"id": user_id})
if not target:
raise HTTPException(status_code=404, detail="User not found")
await db.users.delete_one({"id": user_id})
await db.profiles.delete_many({"user_id": user_id})
return {"ok": True}
# ============ PROFILES ============
@api.get("/profiles", response_model=List[Profile])
async def list_profiles(user: dict = Depends(get_current_user)):
+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>
);
}