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:
parent
e75f379dcb
commit
eef85f36a9
5 changed files with 121 additions and 1 deletions
|
|
@ -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()
|
||||
|
|
|
|||
27
l4d2web/routes/profile_routes.py
Normal file
27
l4d2web/routes/profile_routes.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
33
l4d2web/templates/profile.html
Normal file
33
l4d2web/templates/profile.html
Normal 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 %}
|
||||
58
l4d2web/tests/test_profile.py
Normal file
58
l4d2web/tests/test_profile.py
Normal 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
|
||||
Loading…
Reference in a new issue