Phase 24: add password change page

- GET/POST /account/password with CSRF token and bcrypt re-hash
- 'Password' link in sidebar next to Logout
- change_password.html template

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 04:22:28 -04:00
co-authored by Copilot
parent c4dfcbaf25
commit 64b9481ce0
3 changed files with 90 additions and 0 deletions
+39
View File
@@ -354,6 +354,45 @@ def logout():
return redirect(url_for("login_page")) return redirect(url_for("login_page"))
@app.get("/account/password")
@require_login
def change_password_page():
csrf_token = secrets.token_hex(16)
session["csrf_token"] = csrf_token
return render_template("change_password.html", csrf_token=csrf_token)
@app.post("/account/password")
@require_login
def change_password():
token = request.form.get("csrf_token", "")
if not token or token != session.pop("csrf_token", None):
flash("Invalid request. Please try again.", "error")
return redirect(url_for("change_password_page"))
current = request.form.get("current_password", "")
new_pw = request.form.get("new_password", "")
confirm = request.form.get("confirm_password", "")
user_row = db.get_user_by_id(current_user.id)
if not user_row or not check_password(current, user_row["password_hash"]):
flash("Current password is incorrect.", "error")
return redirect(url_for("change_password_page"))
if len(new_pw) < 8:
flash("New password must be at least 8 characters.", "error")
return redirect(url_for("change_password_page"))
if new_pw != confirm:
flash("Passwords do not match.", "error")
return redirect(url_for("change_password_page"))
from modules.user_auth import hash_password
with db.get_cursor(commit=True) as cur:
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s",
(hash_password(new_pw), current_user.id))
flash("Password updated successfully.", "success")
return redirect(url_for("change_password_page"))
# ── Portal UI pages ──────────────────────────────────────────────────────── # ── Portal UI pages ────────────────────────────────────────────────────────
@app.get("/") @app.get("/")
+2
View File
@@ -45,6 +45,8 @@
<div class="px-3 pb-2 mt-auto text-muted" style="font-size:.75rem;"> <div class="px-3 pb-2 mt-auto text-muted" style="font-size:.75rem;">
<i class="fas fa-user fa-fw"></i> {{ current_user.username }} <i class="fas fa-user fa-fw"></i> {{ current_user.username }}
&nbsp;·&nbsp; &nbsp;·&nbsp;
<a href="{{ url_for('change_password_page') }}" class="text-muted">Password</a>
&nbsp;·&nbsp;
<a href="{{ url_for('logout') }}" class="text-muted">Logout</a> <a href="{{ url_for('logout') }}" class="text-muted">Logout</a>
</div> </div>
<button class="sidebar-collapse-btn" id="sidebarToggle" type="button" aria-label="Collapse sidebar"> <button class="sidebar-collapse-btn" id="sidebarToggle" type="button" aria-label="Collapse sidebar">
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Change password – Yaris OBD Remote{% endblock %}
{% block content %}
<h3 class="text-dark mb-3 fw-bold">Change password</h3>
<div class="row justify-content-center">
<div class="col-12 col-md-6 col-lg-4">
<div class="card shadow-sm">
<div class="card-body p-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'success' if category == 'success' else 'danger' }} py-2 small" role="alert">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('change_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="current_password" class="form-label small fw-semibold">Current password</label>
<input type="password" class="form-control" id="current_password" name="current_password"
autocomplete="current-password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label small fw-semibold">New password</label>
<input type="password" class="form-control" id="new_password" name="new_password"
autocomplete="new-password" required minlength="8">
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label small fw-semibold">Confirm new password</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password"
autocomplete="new-password" required minlength="8">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-key me-1"></i> Update password
</button>
<a href="{{ url_for('portal_index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}