profile: GET /profile page with change-password form

Adds the page reachable from the username link in the header.
Renders the form skeleton; the POST handler lands in the next
commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-11 21:55:34 +02:00
parent e75f379dcb
commit eef85f36a9
No known key found for this signature in database
5 changed files with 121 additions and 1 deletions

View file

@ -16,6 +16,7 @@ from l4d2web.routes.job_routes import bp as job_bp
from l4d2web.routes.log_routes import bp as log_bp
from l4d2web.routes.overlay_routes import bp as overlay_bp
from l4d2web.routes.page_routes import bp as page_bp
from l4d2web.routes.profile_routes import bp as profile_bp
from l4d2web.routes.server_routes import bp as server_bp
from l4d2web.routes.workshop_routes import bp as workshop_bp
from l4d2web.services.job_worker import (
@ -82,6 +83,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
app.register_blueprint(job_bp)
app.register_blueprint(log_bp)
app.register_blueprint(page_bp)
app.register_blueprint(profile_bp)
register_cli(app)
if app.config.get("TESTING"):
reset_login_rate_limits()

View file

@ -0,0 +1,27 @@
from flask import Blueprint, render_template, request
from l4d2web.auth import require_login
bp = Blueprint("profile", __name__)
_ERROR_MESSAGES = {
"fields_required": "Fill in all three fields.",
"mismatch": "New password and confirmation do not match.",
"too_short": "New password must be at least 8 characters.",
"empty": "New password must not be empty.",
"wrong_current": "Current password is incorrect.",
}
@bp.get("/profile")
@require_login
def profile_page() -> str:
error_key = request.args.get("error", "")
success = request.args.get("success") == "1"
return render_template(
"profile.html",
error_message=_ERROR_MESSAGES.get(error_key, ""),
success=success,
)

View file

@ -24,7 +24,7 @@
{% if g.user %}
<nav class="account-nav" aria-label="Account navigation">
{% if g.user.admin %}<a href="/admin">admin</a>{% endif %}
<span class="muted">{{ g.user.username }}</span>
<a class="muted" href="/profile">{{ g.user.username }}</a>
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="link-button" type="submit">logout</button>

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Profile | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Change password</h1>
{% if success %}
<p class="muted">Password changed.</p>
{% endif %}
{% if error_message %}
<p class="error">{{ error_message }}</p>
{% endif %}
<form method="post" action="/profile/password" class="form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>
Current password
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
New password
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
Confirm new password
<input type="password" name="confirm_new_password" autocomplete="new-password" required>
</label>
<button type="submit">Change password</button>
</form>
</section>
{% endblock %}

View file

@ -0,0 +1,58 @@
import pytest
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import User
@pytest.fixture
def app_and_user(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'profile.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as db:
u = User(username="alice", password_digest=hash_password("currentpass"))
db.add(u)
db.flush()
uid = u.id
marker = u.password_changed_at.isoformat()
return app, uid, marker
def _logged_in_client(app, uid, marker):
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = uid
sess["pw_changed_at"] = marker
sess["csrf_token"] = "test-token"
return client
def test_profile_requires_login(app_and_user):
app, _, _ = app_and_user
response = app.test_client().get("/profile", follow_redirects=False)
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_profile_page_renders(app_and_user):
app, uid, marker = app_and_user
client = _logged_in_client(app, uid, marker)
response = client.get("/profile")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert "Change password" in body
assert 'name="current_password"' in body
assert 'name="new_password"' in body
assert 'name="confirm_new_password"' in body
def test_base_template_links_username_to_profile(app_and_user):
app, uid, marker = app_and_user
client = _logged_in_client(app, uid, marker)
response = client.get("/dashboard")
body = response.get_data(as_text=True)
assert 'href="/profile"' in body
assert ">alice<" in body