From 5b191da4711fd47406ce09da9347cf1ce698c01b Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 26 Jul 2026 19:21:50 -0500 Subject: [PATCH] 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 --- backend/models.py | 13 +++ backend/server.py | 59 ++++++++++++ frontend/src/App.js | 4 +- frontend/src/components/Navbar.jsx | 15 ++- frontend/src/pages/Admin.jsx | 5 +- frontend/src/pages/Settings.jsx | 12 ++- frontend/src/pages/Users.jsx | 142 +++++++++++++++++++++++++++++ 7 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 frontend/src/pages/Users.jsx diff --git a/backend/models.py b/backend/models.py index 3f06060..7e110bd 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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"] diff --git a/backend/server.py b/backend/server.py index 0c657d7..e57b5c0 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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)): diff --git a/frontend/src/App.js b/frontend/src/App.js index c35b448..1fdd8af 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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() { } /> } /> } /> + } /> } /> + } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index aa0b2af..dc996a1 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -39,6 +39,7 @@ export const Navbar = () => { Shelf Wishlist {user.is_admin && Admin} + {user.is_admin && Users} )} @@ -49,15 +50,13 @@ export const Navbar = () => { {user.is_admin && ( - <> - - - + )} +
+ {isAdmin && (
{/* TMDB */}
@@ -267,6 +272,7 @@ export default function Settings() { {saving ? "Saving…" : "Save Settings"} + )}
{/* Trakt device-code modal */} diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx new file mode 100644 index 0000000..18e3b55 --- /dev/null +++ b/frontend/src/pages/Users.jsx @@ -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 ( +
+
+ Admin +

Users

+ + + + {showNew && ( +
+ + + + + +
+ )} + +
+
+ Name + Email + Role + Actions +
+ {users.map((u) => ( +
+ {u.name} + {u.email} +
+ +
+
+ +
+
+ ))} +
+
+
+ ); +}