diff --git a/ar-aging-app/backend/app/api/auth.py b/ar-aging-app/backend/app/api/auth.py index fee52a7..e6ccce9 100644 --- a/ar-aging-app/backend/app/api/auth.py +++ b/ar-aging-app/backend/app/api/auth.py @@ -236,3 +236,33 @@ def me(request: Request) -> dict: return {"authenticated": False, "auth_required": False} return {"authenticated": True, "auth_required": True, "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} diff --git a/ar-aging-app/backend/tests/test_auth.py b/ar-aging-app/backend/tests/test_auth.py index 21c5d71..283d576 100644 --- a/ar-aging-app/backend/tests/test_auth.py +++ b/ar-aging-app/backend/tests/test_auth.py @@ -111,6 +111,38 @@ def test_signed_in_identity_overrides_body_name(clean_users): 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): _add_user("gone", "Gone Person", "some-pw-12345") db = SessionLocal() diff --git a/ar-aging-app/frontend/src/api/client.ts b/ar-aging-app/frontend/src/api/client.ts index 57c0e20..fdd126f 100644 --- a/ar-aging-app/frontend/src/api/client.ts +++ b/ar-aging-app/frontend/src/api/client.ts @@ -495,6 +495,10 @@ export const api = { method: "POST", body: JSON.stringify({ username, password }), }), 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("/sessions"), createSession: (body: Partial & { allow_duplicate?: boolean }) => diff --git a/ar-aging-app/frontend/src/pages/Settings.tsx b/ar-aging-app/frontend/src/pages/Settings.tsx index a9d8943..e7defca 100644 --- a/ar-aging-app/frontend/src/pages/Settings.tsx +++ b/ar-aging-app/frontend/src/pages/Settings.tsx @@ -1,10 +1,13 @@ -import { useQuery } from "@tanstack/react-query"; -import { ShieldCheck } from "lucide-react"; +import { FormEvent, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { KeyRound, ShieldCheck } from "lucide-react"; import { api } from "../api/client"; -import { Section } from "../components/ui"; +import { Section, Spinner } from "../components/ui"; +import { useAuth } from "../auth"; export default function Settings() { const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health }); + const { user } = useAuth(); return (
@@ -13,6 +16,8 @@ export default function Settings() {

Application defaults and security posture.

+ {user && } +
{[ @@ -55,3 +60,54 @@ export default function Settings() {
); } + +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 ( +
+
+ + + +
+ + {tooShort && At least 8 characters.} + {mismatch && Passwords don't match.} + {change.isSuccess && Password updated — use it from your next sign-in.} + {change.isError && {(change.error as Error).message}} +
+
+
+ ); +}