Add self-service admin password change (requires current password)

This commit is contained in:
Myron Blair
2026-07-23 22:48:32 -05:00
parent 5b881a902d
commit 19374b694e
3 changed files with 78 additions and 1 deletions
+5
View File
@@ -20,6 +20,11 @@ class UserLogin(BaseModel):
password: str password: str
class PasswordChange(BaseModel):
current_password: str
new_password: str
class UserPublic(BaseModel): class UserPublic(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
id: str id: str
+14 -1
View File
@@ -15,7 +15,7 @@ from starlette.middleware.cors import CORSMiddleware
from motor.motor_asyncio import AsyncIOMotorClient from motor.motor_asyncio import AsyncIOMotorClient
from models import ( from models import (
UserCreate, UserLogin, UserPublic, TokenResponse, UserCreate, UserLogin, UserPublic, TokenResponse, PasswordChange,
Profile, ProfileCreate, ProfileUpdate, RATING_ORDER, Profile, ProfileCreate, ProfileUpdate, RATING_ORDER,
Movie, MovieCreate, MovieUpdate, Movie, MovieCreate, MovieUpdate,
Subtitle, Subtitle,
@@ -235,6 +235,19 @@ async def me(user: dict = Depends(get_current_user)):
return _user_public(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 ============ # ============ PROFILES ============
@api.get("/profiles", response_model=List[Profile]) @api.get("/profiles", response_model=List[Profile])
async def list_profiles(user: dict = Depends(get_current_user)): async def list_profiles(user: dict = Depends(get_current_user)):
+59
View File
@@ -51,6 +51,9 @@ export default function Settings() {
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" }); const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
const [traktModal, setTraktModal] = useState(null); const [traktModal, setTraktModal] = useState(null);
const [show, setShow] = useState({}); 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 load = async () => {
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]); 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(); 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 () => { const enrichAll = async () => {
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return; 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}`); } 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() {
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span> <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">Settings</h1> <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>
<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>
<div className="relative mt-2">
<input
type={pwShow ? "text" : "password"}
value={pw.current_password}
onChange={(e) => setPw({ ...pw, current_password: e.target.value })}
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
data-testid="current-password"
/>
<button type="button" onClick={() => setPwShow(!pwShow)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{pwShow ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
</div>
</label>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">New password (min 8 characters)</span>
<input
type={pwShow ? "text" : "password"}
value={pw.new_password}
onChange={(e) => setPw({ ...pw, new_password: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="new-password"
/>
</label>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Confirm new password</span>
<input
type={pwShow ? "text" : "password"}
value={pw.confirm}
onChange={(e) => setPw({ ...pw, confirm: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="confirm-password"
/>
</label>
<button type="submit" disabled={pwSaving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="change-password-button">
{pwSaving ? "Changing…" : "Change Password"}
</button>
</form>
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form"> <form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
{/* TMDB */} {/* TMDB */}
<section> <section>