Add user password change functionality
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
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.
This commit is contained in:
@@ -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.deps import get_current_user
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
from app.models.models import User
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -9,3 +13,21 @@ router = APIRouter()
|
|||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
||||||
return UserOut.model_validate(user)
|
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"}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from functools import lru_cache
|
|||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
NEXAPG_VERSION = "0.1.1"
|
NEXAPG_VERSION = "0.1.2"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
@@ -21,3 +21,15 @@ class UserUpdate(BaseModel):
|
|||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
role: 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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { QueryInsightsPage } from "./pages/QueryInsightsPage";
|
|||||||
import { AlertsPage } from "./pages/AlertsPage";
|
import { AlertsPage } from "./pages/AlertsPage";
|
||||||
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
||||||
import { ServiceInfoPage } from "./pages/ServiceInfoPage";
|
import { ServiceInfoPage } from "./pages/ServiceInfoPage";
|
||||||
|
import { UserSettingsPage } from "./pages/UserSettingsPage";
|
||||||
|
|
||||||
function Protected({ children }) {
|
function Protected({ children }) {
|
||||||
const { tokens } = useAuth();
|
const { tokens } = useAuth();
|
||||||
@@ -102,6 +103,9 @@ function Layout({ children }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>{me?.email}</div>
|
<div>{me?.email}</div>
|
||||||
<div className="role">{me?.role}</div>
|
<div className="role">{me?.role}</div>
|
||||||
|
<NavLink to="/user-settings" className={({ isActive }) => `profile-btn${isActive ? " active" : ""}`}>
|
||||||
|
User Settings
|
||||||
|
</NavLink>
|
||||||
<button className="logout-btn" onClick={logout}>Logout</button>
|
<button className="logout-btn" onClick={logout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -163,6 +167,7 @@ export function App() {
|
|||||||
<Route path="/query-insights" element={<QueryInsightsPage />} />
|
<Route path="/query-insights" element={<QueryInsightsPage />} />
|
||||||
<Route path="/alerts" element={<AlertsPage />} />
|
<Route path="/alerts" element={<AlertsPage />} />
|
||||||
<Route path="/service-info" element={<ServiceInfoPage />} />
|
<Route path="/service-info" element={<ServiceInfoPage />} />
|
||||||
|
<Route path="/user-settings" element={<UserSettingsPage />} />
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
100
frontend/src/pages/UserSettingsPage.jsx
Normal file
100
frontend/src/pages/UserSettingsPage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="user-settings-page">
|
||||||
|
<h2>User Settings</h2>
|
||||||
|
<p className="muted">Manage your personal account security settings.</p>
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
{message && <div className="test-connection-result ok">{message}</div>}
|
||||||
|
|
||||||
|
<div className="card user-settings-card">
|
||||||
|
<h3>Change Password</h3>
|
||||||
|
<form className="grid two" onSubmit={submit}>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Current password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.current_password}
|
||||||
|
onChange={(e) => setForm({ ...form, current_password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.new_password}
|
||||||
|
onChange={(e) => setForm({ ...form, new_password: e.target.value })}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Confirm new password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.confirm_password}
|
||||||
|
onChange={(e) => setForm({ ...form, confirm_password: e.target.value })}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<button className="primary-btn" type="submit" disabled={busy}>
|
||||||
|
{busy ? "Saving..." : "Update Password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1115,6 +1115,39 @@ button {
|
|||||||
border-color: #38bdf8;
|
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 {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|||||||
Reference in New Issue
Block a user