feat: add self-service profile page with manage navigation

Add /manage/profile page where authenticated users can view and edit
their OIDC profile fields (given_name, family_name, preferred_username,
email, phone_number, picture, locale).

- Create manage/base.html with tab-style nav for Profile/Credentials
- Update credentials.html to extend manage/base.html
- Add GET/POST routes with server-side validation
- Add input styling for tel and url input types
- Add profile test user with pre-filled data in setup_db.py
- Add 19 E2E tests covering structure, navigation, updates, validation
- All 76 E2E tests and 172 Python tests pass
This commit is contained in:
Johan Lundberg 2026-02-18 14:35:17 +01:00
parent 404fcac4dd
commit 8a610a0cd6
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
7 changed files with 395 additions and 5 deletions

View file

@ -1,4 +1,5 @@
from base64 import urlsafe_b64decode
from urllib.parse import urlparse
from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@ -39,6 +40,7 @@ async def credentials_page(request: Request) -> Response:
"webauthn_credentials": webauthn_credentials,
"has_password": password_credential is not None,
"setup": setup,
"active_page": "credentials",
},
)
@ -164,3 +166,84 @@ async def delete_webauthn(request: Request, credential_id_b64: str) -> Response:
await cred_repo.delete_webauthn(userid, credential_id)
return HTMLResponse('<div role="status">Security key removed</div>')
@router.get("/profile", response_class=HTMLResponse)
async def profile_page(request: Request) -> Response:
session_user = get_session_user(request)
if session_user is None:
return RedirectResponse("/login", status_code=303)
userid, username = session_user
user_repo = request.app.state.user_repo
user = await user_repo.get_by_userid(userid)
templates = request.app.state.templates
return templates.TemplateResponse(
request,
"manage/profile.html",
{
"username": username,
"user": user,
"active_page": "profile",
},
)
@router.post("/profile", response_class=HTMLResponse)
async def update_profile(
request: Request,
given_name: str = Form(""),
family_name: str = Form(""),
preferred_username: str = Form(""),
email: str = Form(""),
phone_number: str = Form(""),
picture: str = Form(""),
locale: str = Form(""),
) -> Response:
session_user = get_session_user(request)
if session_user is None:
return RedirectResponse("/login", status_code=303)
userid, _username = session_user
# Validate field lengths
for field_name, value, max_len in [
("Given name", given_name, 255),
("Family name", family_name, 255),
("Display name", preferred_username, 255),
("Email", email, 255),
("Phone number", phone_number, 50),
("Picture URL", picture, 2048),
("Locale", locale, 20),
]:
if len(value) > max_len:
return HTMLResponse(f'<div role="alert">{field_name} is too long</div>')
# Validate email format
if email and "@" not in email:
return HTMLResponse('<div role="alert">Invalid email address</div>')
# Validate picture URL format
if picture:
parsed = urlparse(picture)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return HTMLResponse('<div role="alert">Picture URL must be a valid HTTP or HTTPS URL</div>')
user_repo = request.app.state.user_repo
user = await user_repo.get_by_userid(userid)
updated = user.model_copy(
update={
"given_name": given_name or None,
"family_name": family_name or None,
"preferred_username": preferred_username or None,
"email": email or None,
"phone_number": phone_number or None,
"picture": picture or None,
"locale": locale or None,
}
)
await user_repo.update(updated)
return HTMLResponse('<div role="status">Profile updated</div>')

View file

@ -116,6 +116,36 @@ main {
padding: var(--sp-6) var(--sp-4) var(--sp-12);
}
/* ---------- Manage navigation ---------- */
.manage-nav {
display: flex;
gap: var(--sp-4);
margin-bottom: var(--sp-6);
border-bottom: 1px solid var(--border);
padding-bottom: var(--sp-3);
}
.manage-nav a {
color: var(--fg-muted);
text-decoration: none;
font-weight: 500;
font-size: var(--font-size-sm);
padding-bottom: var(--sp-3);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.manage-nav a:hover {
color: var(--fg);
}
.manage-nav a[aria-current="page"] {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ---------- Typography ---------- */
h1 {
@ -189,7 +219,9 @@ label {
input[type="text"],
input[type="password"],
input[type="email"] {
input[type="email"],
input[type="tel"],
input[type="url"] {
display: block;
width: 100%;
padding: var(--sp-2) var(--sp-3);
@ -206,7 +238,9 @@ input[type="email"] {
input[type="text"]:focus,
input[type="password"]:focus,
input[type="email"]:focus {
input[type="email"]:focus,
input[type="tel"]:focus,
input[type="url"]:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);

View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block content %}
<nav class="manage-nav" aria-label="Account management">
<a href="/manage/profile" {% if active_page == "profile" %}aria-current="page"{% endif %}>Profile</a>
<a href="/manage/credentials" {% if active_page == "credentials" %}aria-current="page"{% endif %}>Credentials</a>
</nav>
{% block manage_content %}{% endblock %}
{% endblock %}

View file

@ -1,8 +1,8 @@
{% extends "base.html" %}
{% extends "manage/base.html" %}
{% block title %}Credentials — Porchlight{% endblock %}
{% block content %}
{% block manage_content %}
<h1>Credentials</h1>
{% if setup %}
@ -51,7 +51,7 @@
</form>
</div>
</section>
{% endblock %}
{% endblock manage_content %}
{% block scripts %}
<script src="/static/webauthn.js" defer></script>

View file

@ -0,0 +1,50 @@
{% extends "manage/base.html" %}
{% block title %}Profile — Porchlight{% endblock %}
{% block manage_content %}
<h1>Profile</h1>
<section>
<h2>Account</h2>
<p><strong>Username:</strong> {{ username }}</p>
</section>
<section>
<h2>Personal information</h2>
<div id="profile-section">
<form hx-post="/manage/profile" hx-target="#profile-status" hx-swap="innerHTML">
<div>
<label for="given_name">Given name</label>
<input type="text" id="given_name" name="given_name" value="{{ user.given_name or '' }}" maxlength="255" autocomplete="given-name">
</div>
<div>
<label for="family_name">Family name</label>
<input type="text" id="family_name" name="family_name" value="{{ user.family_name or '' }}" maxlength="255" autocomplete="family-name">
</div>
<div>
<label for="preferred_username">Display name</label>
<input type="text" id="preferred_username" name="preferred_username" value="{{ user.preferred_username or '' }}" maxlength="255" autocomplete="username">
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" value="{{ user.email or '' }}" maxlength="255" autocomplete="email">
</div>
<div>
<label for="phone_number">Phone number</label>
<input type="tel" id="phone_number" name="phone_number" value="{{ user.phone_number or '' }}" maxlength="50" autocomplete="tel">
</div>
<div>
<label for="picture">Picture URL</label>
<input type="url" id="picture" name="picture" value="{{ user.picture or '' }}" maxlength="2048" autocomplete="photo">
</div>
<div>
<label for="locale">Locale</label>
<input type="text" id="locale" name="locale" value="{{ user.locale or '' }}" maxlength="20" placeholder="e.g. en, sv-SE">
</div>
<div id="profile-status"></div>
<button type="submit">Save profile</button>
</form>
</div>
</section>
{% endblock %}