From 8a610a0cd60cb80191fc55a1e866c6e4572564e7 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 14:35:17 +0100 Subject: [PATCH] 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 --- src/porchlight/manage/routes.py | 83 ++++++++ src/porchlight/static/style.css | 38 +++- src/porchlight/templates/manage/base.html | 9 + .../templates/manage/credentials.html | 6 +- src/porchlight/templates/manage/profile.html | 50 +++++ tests/e2e/profile.spec.js | 193 ++++++++++++++++++ tests/e2e/setup_db.py | 21 ++ 7 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 src/porchlight/templates/manage/base.html create mode 100644 src/porchlight/templates/manage/profile.html create mode 100644 tests/e2e/profile.spec.js diff --git a/src/porchlight/manage/routes.py b/src/porchlight/manage/routes.py index f9dd484..9b9c82e 100644 --- a/src/porchlight/manage/routes.py +++ b/src/porchlight/manage/routes.py @@ -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('
Security key removed
') + + +@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'
{field_name} is too long
') + + # Validate email format + if email and "@" not in email: + return HTMLResponse('
Invalid email address
') + + # Validate picture URL format + if picture: + parsed = urlparse(picture) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return HTMLResponse('
Picture URL must be a valid HTTP or HTTPS URL
') + + 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('
Profile updated
') diff --git a/src/porchlight/static/style.css b/src/porchlight/static/style.css index b6ae1de..7385b3a 100644 --- a/src/porchlight/static/style.css +++ b/src/porchlight/static/style.css @@ -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); diff --git a/src/porchlight/templates/manage/base.html b/src/porchlight/templates/manage/base.html new file mode 100644 index 0000000..15166e6 --- /dev/null +++ b/src/porchlight/templates/manage/base.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block content %} + +{% block manage_content %}{% endblock %} +{% endblock %} diff --git a/src/porchlight/templates/manage/credentials.html b/src/porchlight/templates/manage/credentials.html index ca73824..d32dc42 100644 --- a/src/porchlight/templates/manage/credentials.html +++ b/src/porchlight/templates/manage/credentials.html @@ -1,8 +1,8 @@ -{% extends "base.html" %} +{% extends "manage/base.html" %} {% block title %}Credentials — Porchlight{% endblock %} -{% block content %} +{% block manage_content %}

Credentials

{% if setup %} @@ -51,7 +51,7 @@ -{% endblock %} +{% endblock manage_content %} {% block scripts %} diff --git a/src/porchlight/templates/manage/profile.html b/src/porchlight/templates/manage/profile.html new file mode 100644 index 0000000..320545f --- /dev/null +++ b/src/porchlight/templates/manage/profile.html @@ -0,0 +1,50 @@ +{% extends "manage/base.html" %} + +{% block title %}Profile — Porchlight{% endblock %} + +{% block manage_content %} +

Profile

+ +
+

Account

+

Username: {{ username }}

+
+ +
+

Personal information

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+{% endblock %} diff --git a/tests/e2e/profile.spec.js b/tests/e2e/profile.spec.js new file mode 100644 index 0000000..998d59d --- /dev/null +++ b/tests/e2e/profile.spec.js @@ -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'); + }); + }); +}); diff --git a/tests/e2e/setup_db.py b/tests/e2e/setup_db.py index 0cfe3d8..b654054 100644 --- a/tests/e2e/setup_db.py +++ b/tests/e2e/setup_db.py @@ -79,6 +79,27 @@ async def seed() -> None: await magic_link_service.mark_used(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.close() print(json.dumps(result))