From 07a7236282db053f948be0e7ba6a3408b02f2cee Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 13 Feb 2026 09:32:54 +0100 Subject: [PATCH] Add user password change functionality Introduced a backend API endpoint for changing user passwords with validation. Added a new "User Settings" page in the frontend to allow users to update their passwords, including a matching UI update for navigation and styles. --- backend/app/api/routes/me.py | 26 +++++- backend/app/core/config.py | 2 +- backend/app/schemas/user.py | 14 +++- frontend/src/App.jsx | 5 ++ frontend/src/pages/UserSettingsPage.jsx | 100 ++++++++++++++++++++++++ frontend/src/styles.css | 33 ++++++++ 6 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/UserSettingsPage.jsx diff --git a/backend/app/api/routes/me.py b/backend/app/api/routes/me.py index bd9e0dd..0b43821 100644 --- a/backend/app/api/routes/me.py +++ b/backend/app/api/routes/me.py @@ -1,7 +1,11 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.db import get_db from app.core.deps import get_current_user +from app.core.security import hash_password, verify_password from app.models.models import User -from app.schemas.user import UserOut +from app.schemas.user import UserOut, UserPasswordChange +from app.services.audit import write_audit_log router = APIRouter() @@ -9,3 +13,21 @@ router = APIRouter() @router.get("/me", response_model=UserOut) async def me(user: User = Depends(get_current_user)) -> UserOut: return UserOut.model_validate(user) + + +@router.post("/me/password") +async def change_password( + payload: UserPasswordChange, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + if not verify_password(payload.current_password, user.password_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect") + + if verify_password(payload.new_password, user.password_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be different") + + user.password_hash = hash_password(payload.new_password) + await db.commit() + await write_audit_log(db, action="auth.password_change", user_id=user.id, payload={}) + return {"status": "ok"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 57e643c..f04aa50 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -2,7 +2,7 @@ from functools import lru_cache from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -NEXAPG_VERSION = "0.1.1" +NEXAPG_VERSION = "0.1.2" class Settings(BaseSettings): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 0c52067..ad725b6 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,5 +1,5 @@ from datetime import datetime -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator class UserOut(BaseModel): @@ -21,3 +21,15 @@ class UserUpdate(BaseModel): email: EmailStr | None = None password: str | None = None role: str | None = None + + +class UserPasswordChange(BaseModel): + current_password: str + new_password: str + + @field_validator("new_password") + @classmethod + def validate_new_password(cls, value: str) -> str: + if len(value) < 8: + raise ValueError("new_password must be at least 8 characters") + return value diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d2689a6..ea8d3fc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import { QueryInsightsPage } from "./pages/QueryInsightsPage"; import { AlertsPage } from "./pages/AlertsPage"; import { AdminUsersPage } from "./pages/AdminUsersPage"; import { ServiceInfoPage } from "./pages/ServiceInfoPage"; +import { UserSettingsPage } from "./pages/UserSettingsPage"; function Protected({ children }) { const { tokens } = useAuth(); @@ -102,6 +103,9 @@ function Layout({ children }) {
{me?.email}
{me?.role}
+ `profile-btn${isActive ? " active" : ""}`}> + User Settings + @@ -163,6 +167,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/UserSettingsPage.jsx b/frontend/src/pages/UserSettingsPage.jsx new file mode 100644 index 0000000..6a16151 --- /dev/null +++ b/frontend/src/pages/UserSettingsPage.jsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import { apiFetch } from "../api"; +import { useAuth } from "../state"; + +export function UserSettingsPage() { + const { tokens, refresh } = useAuth(); + const [form, setForm] = useState({ + current_password: "", + new_password: "", + confirm_password: "", + }); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async (e) => { + e.preventDefault(); + setMessage(""); + setError(""); + if (form.new_password.length < 8) { + setError("New password must be at least 8 characters."); + return; + } + if (form.new_password !== form.confirm_password) { + setError("Password confirmation does not match."); + return; + } + + try { + setBusy(true); + await apiFetch( + "/me/password", + { + method: "POST", + body: JSON.stringify({ + current_password: form.current_password, + new_password: form.new_password, + }), + }, + tokens, + refresh + ); + setForm({ current_password: "", new_password: "", confirm_password: "" }); + setMessage("Password changed successfully."); + } catch (e) { + setError(String(e.message || e)); + } finally { + setBusy(false); + } + }; + + return ( +
+

User Settings

+

Manage your personal account security settings.

+ {error &&
{error}
} + {message &&
{message}
} + +
+

Change Password

+
+
+ + setForm({ ...form, current_password: e.target.value })} + required + /> +
+
+ + setForm({ ...form, new_password: e.target.value })} + minLength={8} + required + /> +
+
+ + setForm({ ...form, confirm_password: e.target.value })} + minLength={8} + required + /> +
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9cdda03..6c92e70 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1115,6 +1115,39 @@ button { border-color: #38bdf8; } +.profile-btn { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 8px; + border: 1px solid #3a63a1; + border-radius: 10px; + background: linear-gradient(180deg, #15315d, #11274c); + color: #e7f2ff; + min-height: 40px; + font-weight: 650; +} + +.profile-btn:hover { + border-color: #58b0e8; + background: linear-gradient(180deg, #1a427a, #15335f); +} + +.profile-btn.active { + border-color: #66c7f4; + box-shadow: inset 0 0 0 1px #66c7f455; +} + +.user-settings-page h2 { + margin-top: 4px; + margin-bottom: 4px; +} + +.user-settings-card { + max-width: 760px; +} + table { width: 100%; border-collapse: collapse;