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 %}
+
+ Profile
+ Credentials
+
+{% 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))