diff --git a/backend/models.py b/backend/models.py index 4d0cfcf..3f06060 100644 --- a/backend/models.py +++ b/backend/models.py @@ -20,6 +20,11 @@ class UserLogin(BaseModel): password: str +class PasswordChange(BaseModel): + current_password: str + new_password: str + + class UserPublic(BaseModel): model_config = ConfigDict(extra="ignore") id: str diff --git a/backend/server.py b/backend/server.py index a2a6a83..295b426 100644 --- a/backend/server.py +++ b/backend/server.py @@ -15,7 +15,7 @@ from starlette.middleware.cors import CORSMiddleware from motor.motor_asyncio import AsyncIOMotorClient from models import ( - UserCreate, UserLogin, UserPublic, TokenResponse, + UserCreate, UserLogin, UserPublic, TokenResponse, PasswordChange, Profile, ProfileCreate, ProfileUpdate, RATING_ORDER, Movie, MovieCreate, MovieUpdate, Subtitle, @@ -235,6 +235,19 @@ async def me(user: dict = Depends(get_current_user)): return _user_public(user) +@api.post("/auth/change-password") +async def change_password(payload: PasswordChange, user: dict = Depends(get_current_user)): + full_user = await db.users.find_one({"id": user["id"]}) + if not full_user or not verify_password(payload.current_password, full_user["password_hash"]): + raise HTTPException(status_code=401, detail="Current password is incorrect") + if len(payload.new_password) < 8: + raise HTTPException(status_code=400, detail="New password must be at least 8 characters") + await db.users.update_one( + {"id": user["id"]}, {"$set": {"password_hash": hash_password(payload.new_password)}} + ) + 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/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 2b4c342..eb45fdc 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -51,6 +51,9 @@ export default function Settings() { const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" }); const [traktModal, setTraktModal] = useState(null); const [show, setShow] = useState({}); + const [pw, setPw] = useState({ current_password: "", new_password: "", confirm: "" }); + const [pwShow, setPwShow] = useState(false); + const [pwSaving, setPwSaving] = useState(false); const load = async () => { const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]); @@ -97,6 +100,19 @@ export default function Settings() { await api.delete("/trakt/disconnect"); toast.success("Trakt disconnected"); load(); }; + const changePassword = async (e) => { + e.preventDefault(); + if (pw.new_password.length < 8) { toast.error("New password must be at least 8 characters"); return; } + if (pw.new_password !== pw.confirm) { toast.error("New passwords don't match"); return; } + setPwSaving(true); + try { + await api.post("/auth/change-password", { current_password: pw.current_password, new_password: pw.new_password }); + toast.success("Password changed"); + setPw({ current_password: "", new_password: "", confirm: "" }); + } catch (err) { toast.error(err.response?.data?.detail || "Could not change password"); } + finally { setPwSaving(false); } + }; + const enrichAll = async () => { if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return; try { const { data } = await api.post("/tmdb/enrich-all"); toast.success(`Enriched ${data.enriched}, skipped ${data.skipped}, failed ${data.failed}`); } @@ -110,6 +126,49 @@ export default function Settings() { Admin