Merge branch 'feature/profile-page'
This commit is contained in:
commit
b22dabbbb0
7 changed files with 395 additions and 5 deletions
|
|
@ -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>')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
9
src/porchlight/templates/manage/base.html
Normal file
9
src/porchlight/templates/manage/base.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
50
src/porchlight/templates/manage/profile.html
Normal file
50
src/porchlight/templates/manage/profile.html
Normal 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
193
tests/e2e/profile.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue