Merge branch 'feature/profile-page'

This commit is contained in:
Johan Lundberg 2026-02-18 14:38:53 +01:00
commit b22dabbbb0
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 base64 import urlsafe_b64decode
from urllib.parse import urlparse
from fastapi import APIRouter, Form, Request, Response from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@ -39,6 +40,7 @@ async def credentials_page(request: Request) -> Response:
"webauthn_credentials": webauthn_credentials, "webauthn_credentials": webauthn_credentials,
"has_password": password_credential is not None, "has_password": password_credential is not None,
"setup": setup, "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) await cred_repo.delete_webauthn(userid, credential_id)
return HTMLResponse('<div role="status">Security key removed</div>') 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); 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 ---------- */ /* ---------- Typography ---------- */
h1 { h1 {
@ -189,7 +219,9 @@ label {
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
input[type="email"] { input[type="email"],
input[type="tel"],
input[type="url"] {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3);
@ -206,7 +238,9 @@ input[type="email"] {
input[type="text"]:focus, input[type="text"]:focus,
input[type="password"]: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); border-color: var(--accent);
outline: none; outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); 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 title %}Credentials — Porchlight{% endblock %}
{% block content %} {% block manage_content %}
<h1>Credentials</h1> <h1>Credentials</h1>
{% if setup %} {% if setup %}
@ -51,7 +51,7 @@
</form> </form>
</div> </div>
</section> </section>
{% endblock %} {% endblock manage_content %}
{% block scripts %} {% block scripts %}
<script src="/static/webauthn.js" defer></script> <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 %}

193
tests/e2e/profile.spec.js Normal file
View file

@ -0,0 +1,193 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
test.describe('Profile page', () => {
test.describe('Auth guard', () => {
test('unauthenticated /manage/profile redirects to /login', async ({ page }) => {
await page.goto('/manage/profile');
await page.waitForURL('**/login', { timeout: 5000 });
expect(page.url()).toContain('/login');
});
});
test.describe('Page structure', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('#username', fixtures.profile_username);
await page.fill('#password', fixtures.profile_password);
await page.click('form[hx-post="/login/password"] button[type="submit"]');
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
await page.goto('/manage/profile');
});
test('title contains Profile and Porchlight', async ({ page }) => {
await expect(page).toHaveTitle(/Profile/);
await expect(page).toHaveTitle(/Porchlight/);
});
test('H1 says "Profile"', async ({ page }) => {
await expect(page.locator('h1')).toHaveText('Profile');
});
test('shows username as read-only', async ({ page }) => {
await expect(page.locator('section:has(h2:has-text("Account"))')).toContainText('profileuser');
});
test('has all profile form fields', async ({ page }) => {
await expect(page.locator('#given_name')).toBeVisible();
await expect(page.locator('#family_name')).toBeVisible();
await expect(page.locator('#preferred_username')).toBeVisible();
await expect(page.locator('#email')).toBeVisible();
await expect(page.locator('#phone_number')).toBeVisible();
await expect(page.locator('#picture')).toBeVisible();
await expect(page.locator('#locale')).toBeVisible();
});
test('fields are pre-filled with existing data', async ({ page }) => {
await expect(page.locator('#given_name')).toHaveValue('Alice');
await expect(page.locator('#family_name')).toHaveValue('Smith');
await expect(page.locator('#preferred_username')).toHaveValue('asmith');
await expect(page.locator('#email')).toHaveValue('alice@example.com');
await expect(page.locator('#phone_number')).toHaveValue('+1234567890');
await expect(page.locator('#picture')).toHaveValue('https://example.com/alice.jpg');
await expect(page.locator('#locale')).toHaveValue('en');
});
test('has save button', async ({ page }) => {
await expect(page.locator('button[type="submit"]')).toHaveText('Save profile');
});
});
test.describe('Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('#username', fixtures.profile_username);
await page.fill('#password', fixtures.profile_password);
await page.click('form[hx-post="/login/password"] button[type="submit"]');
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
});
test('manage nav is visible on credentials page', async ({ page }) => {
await expect(page.locator('nav.manage-nav')).toBeVisible();
});
test('credentials link is active on credentials page', async ({ page }) => {
const credLink = page.locator('nav.manage-nav a[href="/manage/credentials"]');
await expect(credLink).toHaveAttribute('aria-current', 'page');
});
test('can navigate from credentials to profile', async ({ page }) => {
await page.click('nav.manage-nav a[href="/manage/profile"]');
await page.waitForURL('**/manage/profile', { timeout: 5000 });
await expect(page.locator('h1')).toHaveText('Profile');
});
test('profile link is active on profile page', async ({ page }) => {
await page.goto('/manage/profile');
const profileLink = page.locator('nav.manage-nav a[href="/manage/profile"]');
await expect(profileLink).toHaveAttribute('aria-current', 'page');
});
test('can navigate from profile to credentials', async ({ page }) => {
await page.goto('/manage/profile');
await page.click('nav.manage-nav a[href="/manage/credentials"]');
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
await expect(page.locator('h1')).toHaveText('Credentials');
});
});
test.describe('Profile update', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('#username', fixtures.profile_username);
await page.fill('#password', fixtures.profile_password);
await page.click('form[hx-post="/login/password"] button[type="submit"]');
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
await page.goto('/manage/profile');
});
test('successfully updates profile', async ({ page }) => {
await page.fill('#given_name', 'Bob');
await page.fill('#family_name', 'Jones');
await page.click('button[type="submit"]');
const status = page.locator('#profile-status [role="status"]');
await expect(status).toBeVisible({ timeout: 5000 });
await expect(status).toContainText('Profile updated');
});
test('updated values persist after reload', async ({ page }) => {
await page.fill('#given_name', 'Carol');
await page.fill('#family_name', 'Davis');
await page.fill('#preferred_username', 'cdavis');
await page.click('button[type="submit"]');
const status = page.locator('#profile-status [role="status"]');
await expect(status).toBeVisible({ timeout: 5000 });
await page.reload();
await expect(page.locator('#given_name')).toHaveValue('Carol');
await expect(page.locator('#family_name')).toHaveValue('Davis');
await expect(page.locator('#preferred_username')).toHaveValue('cdavis');
});
test('can clear optional fields', async ({ page }) => {
await page.fill('#phone_number', '');
await page.fill('#picture', '');
await page.fill('#locale', '');
await page.click('button[type="submit"]');
const status = page.locator('#profile-status [role="status"]');
await expect(status).toBeVisible({ timeout: 5000 });
await expect(status).toContainText('Profile updated');
await page.reload();
await expect(page.locator('#phone_number')).toHaveValue('');
await expect(page.locator('#picture')).toHaveValue('');
await expect(page.locator('#locale')).toHaveValue('');
});
});
test.describe('Validation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('#username', fixtures.profile_username);
await page.fill('#password', fixtures.profile_password);
await page.click('form[hx-post="/login/password"] button[type="submit"]');
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
await page.goto('/manage/profile');
});
test('shows error for invalid email', async ({ page }) => {
// Bypass HTML5 validation by removing type attribute
await page.locator('#email').evaluate(el => el.type = 'text');
await page.fill('#email', 'not-an-email');
await page.click('button[type="submit"]');
const alert = page.locator('#profile-status [role="alert"]');
await expect(alert).toBeVisible({ timeout: 5000 });
await expect(alert).toContainText('Invalid email');
});
test('shows error for invalid picture URL', async ({ page }) => {
// Bypass HTML5 validation by removing type attribute
await page.locator('#picture').evaluate(el => el.type = 'text');
await page.fill('#picture', 'not-a-url');
await page.click('button[type="submit"]');
const alert = page.locator('#profile-status [role="alert"]');
await expect(alert).toBeVisible({ timeout: 5000 });
await expect(alert).toContainText('Picture URL');
});
test('email input has type="email"', async ({ page }) => {
await expect(page.locator('#email')).toHaveAttribute('type', 'email');
});
test('picture input has type="url"', async ({ page }) => {
await expect(page.locator('#picture')).toHaveAttribute('type', 'url');
});
});
});

View file

@ -79,6 +79,27 @@ async def seed() -> None:
await magic_link_service.mark_used(expired_link.token) await magic_link_service.mark_used(expired_link.token)
result["used_token"] = expired_link.token result["used_token"] = expired_link.token
# 5. Create a user with profile data for profile management tests
profile_user = User(
userid="test-user-04",
username="profileuser",
given_name="Alice",
family_name="Smith",
preferred_username="asmith",
email="alice@example.com",
phone_number="+1234567890",
picture="https://example.com/alice.jpg",
locale="en",
groups=["users"],
)
await user_repo.create(profile_user)
profile_password_hash = password_service.hash("profilepass123")
await cred_repo.create_password(
PasswordCredential(user_id=profile_user.userid, password_hash=profile_password_hash)
)
result["profile_username"] = "profileuser"
result["profile_password"] = "profilepass123"
await db.commit() await db.commit()
await db.close() await db.close()
print(json.dumps(result)) print(json.dumps(result))