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
+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)):