Add change-password: users update their own password from Settings

POST /api/auth/change-password requires the current password; admins
still reset others via manage.py set-password. UI on the Settings page
(signed-in users only) with match/length validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main
Talha Ahmed 2026-08-19 19:07:28 +05:00
parent d823a45cb2
commit 11bceedaca
4 changed files with 125 additions and 3 deletions

View File

@ -236,3 +236,33 @@ def me(request: Request) -> dict:
return {"authenticated": False, "auth_required": False} return {"authenticated": False, "auth_required": False}
return {"authenticated": True, "auth_required": True, return {"authenticated": True, "auth_required": True,
"username": user.username, "display_name": user.display_name} "username": user.username, "display_name": user.display_name}
class ChangePasswordIn(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
def change_password(body: ChangePasswordIn, request: Request,
db: OrmSession = Depends(db_dep)) -> dict:
"""Signed-in users change their own password (admins reset others via manage.py).
Requires the current password so a walked-away-from session can't be hijacked into a
permanent account takeover. Existing tokens stay valid until their normal expiry."""
user = current_user(request)
if user is None:
raise HTTPException(401, "Sign in to change your password.")
row = db.get(models.User, user.id)
if row is None or not row.is_active:
raise HTTPException(401, "Account not found or deactivated.")
if not verify_password(body.current_password, row.password_hash):
raise HTTPException(400, "The current password is wrong.")
if len(body.new_password) < 8:
raise HTTPException(400, "The new password must be at least 8 characters.")
if body.new_password == body.current_password:
raise HTTPException(400, "The new password must be different from the current one.")
row.password_hash = hash_password(body.new_password)
db.commit()
logger.info("password changed: %s", row.username)
return {"changed": True}

View File

@ -111,6 +111,38 @@ def test_signed_in_identity_overrides_body_name(clean_users):
c.delete(f"/api/sessions/{sid}", headers=hdr) c.delete(f"/api/sessions/{sid}", headers=hdr)
def test_change_password_flow(clean_users):
_add_user("changer", "Change Person", "old-password-1")
with TestClient(app) as c:
token = c.post("/api/auth/login", json={
"username": "changer", "password": "old-password-1"}).json()["token"]
hdr = {"Authorization": f"Bearer {token}"}
# Wrong current password / too short / unchanged are all refused.
assert c.post("/api/auth/change-password", headers=hdr, json={
"current_password": "nope", "new_password": "new-password-2"}).status_code == 400
assert c.post("/api/auth/change-password", headers=hdr, json={
"current_password": "old-password-1", "new_password": "short"}).status_code == 400
assert c.post("/api/auth/change-password", headers=hdr, json={
"current_password": "old-password-1",
"new_password": "old-password-1"}).status_code == 400
# Not signed in -> refused.
assert c.post("/api/auth/change-password", json={
"current_password": "old-password-1",
"new_password": "new-password-2"}).status_code == 401
r = c.post("/api/auth/change-password", headers=hdr, json={
"current_password": "old-password-1", "new_password": "new-password-2"})
assert r.status_code == 200 and r.json()["changed"] is True
# Old password dead, new one works, existing token still valid until expiry.
assert c.post("/api/auth/login", json={
"username": "changer", "password": "old-password-1"}).status_code == 401
assert c.post("/api/auth/login", json={
"username": "changer", "password": "new-password-2"}).status_code == 200
assert c.get("/api/auth/me", headers=hdr).status_code == 200
def test_inactive_user_cannot_login(clean_users): def test_inactive_user_cannot_login(clean_users):
_add_user("gone", "Gone Person", "some-pw-12345") _add_user("gone", "Gone Person", "some-pw-12345")
db = SessionLocal() db = SessionLocal()

View File

@ -495,6 +495,10 @@ export const api = {
method: "POST", body: JSON.stringify({ username, password }), method: "POST", body: JSON.stringify({ username, password }),
}), }),
me: () => req<{ authenticated: boolean; username?: string; display_name?: string }>("/auth/me"), me: () => req<{ authenticated: boolean; username?: string; display_name?: string }>("/auth/me"),
changePassword: (current_password: string, new_password: string) =>
req<{ changed: boolean }>("/auth/change-password", {
method: "POST", body: JSON.stringify({ current_password, new_password }),
}),
listSessions: () => req<SessionT[]>("/sessions"), listSessions: () => req<SessionT[]>("/sessions"),
createSession: (body: Partial<SessionT> & { allow_duplicate?: boolean }) => createSession: (body: Partial<SessionT> & { allow_duplicate?: boolean }) =>

View File

@ -1,10 +1,13 @@
import { useQuery } from "@tanstack/react-query"; import { FormEvent, useState } from "react";
import { ShieldCheck } from "lucide-react"; import { useMutation, useQuery } from "@tanstack/react-query";
import { KeyRound, ShieldCheck } from "lucide-react";
import { api } from "../api/client"; import { api } from "../api/client";
import { Section } from "../components/ui"; import { Section, Spinner } from "../components/ui";
import { useAuth } from "../auth";
export default function Settings() { export default function Settings() {
const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health }); const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health });
const { user } = useAuth();
return ( return (
<div className="p-6 max-w-3xl mx-auto space-y-6"> <div className="p-6 max-w-3xl mx-auto space-y-6">
@ -13,6 +16,8 @@ export default function Settings() {
<p className="text-sm text-subink">Application defaults and security posture.</p> <p className="text-sm text-subink">Application defaults and security posture.</p>
</header> </header>
{user && <ChangePassword username={user.username} />}
<Section title="Processing defaults"> <Section title="Processing defaults">
<dl className="divide-y divide-line"> <dl className="divide-y divide-line">
{[ {[
@ -55,3 +60,54 @@ export default function Settings() {
</div> </div>
); );
} }
function ChangePassword({ username }: { username: string }) {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const change = useMutation({
mutationFn: () => api.changePassword(current, next),
onSuccess: () => { setCurrent(""); setNext(""); setRepeat(""); },
});
const mismatch = repeat.length > 0 && next !== repeat;
const tooShort = next.length > 0 && next.length < 8;
const ready = current && next.length >= 8 && next === repeat;
const submit = (e: FormEvent) => {
e.preventDefault();
if (ready) change.mutate();
};
return (
<Section title="Change my password"
subtitle={`Signed in as ${username}. Forgot the current one? An administrator can reset it on the server (manage.py set-password).`}>
<form onSubmit={submit} className="p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
<label className="text-sm">
<span className="block text-xs font-medium text-subink mb-1">Current password</span>
<input className="input" type="password" autoComplete="current-password"
value={current} onChange={(e) => setCurrent(e.target.value)} />
</label>
<label className="text-sm">
<span className="block text-xs font-medium text-subink mb-1">New password (min 8)</span>
<input className="input" type="password" autoComplete="new-password"
value={next} onChange={(e) => setNext(e.target.value)} />
</label>
<label className="text-sm">
<span className="block text-xs font-medium text-subink mb-1">Repeat new password</span>
<input className="input" type="password" autoComplete="new-password"
value={repeat} onChange={(e) => setRepeat(e.target.value)} />
</label>
<div className="sm:col-span-3 flex items-center gap-3 flex-wrap">
<button className="btn-primary" type="submit" disabled={!ready || change.isPending}>
{change.isPending ? <Spinner /> : <KeyRound size={15} />} Update password
</button>
{tooShort && <span className="text-xs text-warn">At least 8 characters.</span>}
{mismatch && <span className="text-xs text-bad">Passwords don't match.</span>}
{change.isSuccess && <span className="text-xs text-ok">Password updated use it from your next sign-in.</span>}
{change.isError && <span className="text-xs text-bad">{(change.error as Error).message}</span>}
</div>
</form>
</Section>
);
}