From 7e9eeb13395857446130ec5fcbbba792f5d9cf2f Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 11:00:47 +0100 Subject: [PATCH 01/11] feat: add search_users and count_users to user repository --- src/porchlight/store/protocols.py | 4 ++ src/porchlight/store/sqlite/repositories.py | 26 +++++++++++++ tests/test_store/test_sqlite_user_repo.py | 43 +++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/src/porchlight/store/protocols.py b/src/porchlight/store/protocols.py index 2e21a11..f65588d 100644 --- a/src/porchlight/store/protocols.py +++ b/src/porchlight/store/protocols.py @@ -20,6 +20,10 @@ class UserRepository(Protocol): async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]: ... + async def search_users(self, query: str, offset: int = 0, limit: int = 100) -> list[User]: ... + + async def count_users(self, query: str | None = None) -> int: ... + async def delete(self, userid: str) -> bool: ... diff --git a/src/porchlight/store/sqlite/repositories.py b/src/porchlight/store/sqlite/repositories.py index 2854873..1a2a05f 100644 --- a/src/porchlight/store/sqlite/repositories.py +++ b/src/porchlight/store/sqlite/repositories.py @@ -136,6 +136,32 @@ class SQLiteUserRepository: users.append(self._row_to_user(row, groups)) return users + async def search_users(self, query: str, offset: int = 0, limit: int = 100) -> list[User]: + pattern = f"%{query}%" + async with self._db.execute( + "SELECT * FROM users WHERE username LIKE ? OR email LIKE ? ORDER BY username LIMIT ? OFFSET ?", + (pattern, pattern, limit, offset), + ) as cursor: + rows = await cursor.fetchall() + users = [] + for row in rows: + groups = await self._get_groups(row["userid"]) + users.append(self._row_to_user(row, groups)) + return users + + async def count_users(self, query: str | None = None) -> int: + if query: + pattern = f"%{query}%" + async with self._db.execute( + "SELECT COUNT(*) FROM users WHERE username LIKE ? OR email LIKE ?", + (pattern, pattern), + ) as cursor: + row = await cursor.fetchone() + else: + async with self._db.execute("SELECT COUNT(*) FROM users") as cursor: + row = await cursor.fetchone() + return row[0] if row else 0 + async def delete(self, userid: str) -> bool: cursor = await self._db.execute("DELETE FROM users WHERE userid = ?", (userid,)) await self._db.commit() diff --git a/tests/test_store/test_sqlite_user_repo.py b/tests/test_store/test_sqlite_user_repo.py index ff2c5e6..0239a08 100644 --- a/tests/test_store/test_sqlite_user_repo.py +++ b/tests/test_store/test_sqlite_user_repo.py @@ -159,6 +159,49 @@ async def test_create_duplicate_username(user_repo: SQLiteUserRepository) -> Non await user_repo.create(_make_user(userid="different-id", username="alice")) +async def test_search_users_by_username(user_repo: SQLiteUserRepository) -> None: + await user_repo.create(_make_user(userid="id-1", username="sample_user")) + results = await user_repo.search_users("sample", offset=0, limit=100) + assert len(results) == 1 + assert results[0].userid == "id-1" + + +async def test_search_users_by_email(user_repo: SQLiteUserRepository) -> None: + await user_repo.create(_make_user(email="alice@example.com")) + results = await user_repo.search_users("alice", offset=0, limit=100) + assert len(results) == 1 + + +async def test_search_users_no_match(user_repo: SQLiteUserRepository) -> None: + await user_repo.create(_make_user()) + results = await user_repo.search_users("nonexistent", offset=0, limit=100) + assert len(results) == 0 + + +async def test_search_users_pagination(user_repo: SQLiteUserRepository) -> None: + for i in range(5): + await user_repo.create(_make_user(userid=f"id-{i}", username=f"user{i}", groups=["users"])) + page1 = await user_repo.search_users("user", offset=0, limit=2) + page2 = await user_repo.search_users("user", offset=2, limit=2) + assert len(page1) == 2 + assert len(page2) == 2 + assert page1[0].username != page2[0].username + + +async def test_count_users_no_query(user_repo: SQLiteUserRepository) -> None: + await user_repo.create(_make_user()) + count = await user_repo.count_users() + assert count == 1 + + +async def test_count_users_with_query(user_repo: SQLiteUserRepository) -> None: + await user_repo.create(_make_user()) + count = await user_repo.count_users(query="alice") + assert count == 1 + count = await user_repo.count_users(query="nonexistent") + assert count == 0 + + async def test_roundtrip_preserves_all_fields(user_repo: SQLiteUserRepository) -> None: user = _make_user( preferred_username="ally", From dd1f85d8d32820cf35a6b662c78d6b0ab56a1a3f Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 11:18:50 +0100 Subject: [PATCH 02/11] feat: add admin router with admin group guard --- src/porchlight/admin/__init__.py | 0 src/porchlight/admin/routes.py | 34 ++++++++++++++++++ src/porchlight/app.py | 2 ++ tests/test_admin/__init__.py | 0 tests/test_admin/test_admin_guard.py | 53 ++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 src/porchlight/admin/__init__.py create mode 100644 src/porchlight/admin/routes.py create mode 100644 tests/test_admin/__init__.py create mode 100644 tests/test_admin/test_admin_guard.py diff --git a/src/porchlight/admin/__init__.py b/src/porchlight/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/porchlight/admin/routes.py b/src/porchlight/admin/routes.py new file mode 100644 index 0000000..78500c4 --- /dev/null +++ b/src/porchlight/admin/routes.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse + +from porchlight.dependencies import get_session_user +from porchlight.models import User + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +async def _get_admin_user(request: Request) -> User | None: + """Return the current user if they are an admin, else None.""" + session_user = get_session_user(request) + if session_user is None: + return None + userid, _username = session_user + user_repo = request.app.state.user_repo + user = await user_repo.get_by_userid(userid) + if user is None or "admin" not in user.groups: + return None + return user + + +@router.get("/users", response_class=HTMLResponse) +async def users_list(request: Request) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + # Placeholder — will be implemented in Task 4 + return HTMLResponse("Admin users list") diff --git a/src/porchlight/app.py b/src/porchlight/app.py index f0bc34f..a27fc1c 100644 --- a/src/porchlight/app.py +++ b/src/porchlight/app.py @@ -10,6 +10,7 @@ from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware from porchlight.authn.password import PasswordService +from porchlight.admin.routes import router as admin_router from porchlight.authn.routes import router as authn_router from porchlight.authn.webauthn import WebAuthnService from porchlight.config import Settings, StorageBackend @@ -114,6 +115,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static") # Routers + app.include_router(admin_router) app.include_router(authn_router) app.include_router(manage_router) app.include_router(oidc_router) diff --git a/tests/test_admin/__init__.py b/tests/test_admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_admin/test_admin_guard.py b/tests/test_admin/test_admin_guard.py new file mode 100644 index 0000000..f58d187 --- /dev/null +++ b/tests/test_admin/test_admin_guard.py @@ -0,0 +1,53 @@ +from datetime import UTC, datetime + +import pytest +from argon2 import PasswordHasher +from httpx import AsyncClient + +from porchlight.authn.password import PasswordService +from porchlight.models import PasswordCredential, User + + +async def _login( + client: AsyncClient, username: str = "alice", password: str = "testpass", *, groups: list[str] | None = None +) -> None: + """Helper: create user + password credential and log in via POST /login/password.""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = await user_repo.get_by_username(username) + if user is None: + user = User( + userid="test-user-01", + username=username, + groups=groups or [], + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + existing = await cred_repo.get_password_by_user(user.userid) + if existing is None: + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password))) + + await client.post( + "/login/password", + data={"username": username, "password": password}, + headers={"HX-Request": "true"}, + ) + + +@pytest.mark.asyncio +async def test_admin_users_redirects_unauthenticated(client: AsyncClient) -> None: + response = await client.get("/admin/users", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/login" + + +@pytest.mark.asyncio +async def test_admin_users_403_for_non_admin(client: AsyncClient) -> None: + await _login(client, username="regularuser", password="password123", groups=["users"]) + response = await client.get("/admin/users", follow_redirects=False) + assert response.status_code == 403 From f2d669d705814e51ca42b9f0fd7e3553208d7ab7 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 11:30:35 +0100 Subject: [PATCH 03/11] feat: add admin base template and CSS styles --- src/porchlight/static/style.css | 163 +++++++++++++++++++++++ src/porchlight/templates/admin/base.html | 9 ++ 2 files changed, 172 insertions(+) create mode 100644 src/porchlight/templates/admin/base.html diff --git a/src/porchlight/static/style.css b/src/porchlight/static/style.css index 7385b3a..cc7fa9c 100644 --- a/src/porchlight/static/style.css +++ b/src/porchlight/static/style.css @@ -146,6 +146,169 @@ main { border-bottom-color: var(--accent); } +/* ---------- Admin ---------- */ + +.admin-nav { + display: flex; + align-items: center; + gap: var(--sp-4); + margin-bottom: var(--sp-6); + border-bottom: 1px solid var(--border); + padding-bottom: var(--sp-3); +} + +.admin-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; +} + +.admin-nav a:hover { + color: var(--fg); +} + +.admin-nav a[aria-current="page"] { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.admin-badge { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--accent-fg); + background: var(--accent); + padding: var(--sp-1) var(--sp-2); + border-radius: var(--radius); + line-height: 1; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); +} + +.admin-table th, +.admin-table td { + text-align: left; + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--border); +} + +.admin-table th { + font-weight: 600; + color: var(--fg-muted); + font-size: var(--font-size-sm); +} + +.admin-table tbody tr:hover { + background: var(--surface); +} + +.status-badge { + display: inline-block; + font-size: var(--font-size-sm); + font-weight: 500; + padding: var(--sp-1) var(--sp-2); + border-radius: var(--radius); + line-height: 1; +} + +.status-active { + background: var(--success-bg); + color: var(--success-fg); +} + +.status-inactive { + background: var(--error-bg); + color: var(--error-fg); +} + +.group-tag { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + font-size: var(--font-size-sm); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-1) var(--sp-2); + line-height: 1; +} + +.group-tag button { + background: none; + border: none; + color: var(--fg-muted); + cursor: pointer; + padding: 0; + font-size: var(--font-size-sm); + line-height: 1; +} + +.group-tag button:hover { + color: var(--error-fg); +} + +.admin-search { + display: flex; + gap: var(--sp-2); + margin-bottom: var(--sp-4); +} + +.admin-search input[type="text"] { + flex: 1; + margin-bottom: 0; +} + +.pagination { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: var(--sp-4); + font-size: var(--font-size-sm); +} + +.admin-detail section { + margin-bottom: var(--sp-6); +} + +.invite-url { + font-family: ui-monospace, monospace; + font-size: var(--font-size-sm); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-3) var(--sp-4); + word-break: break-all; + margin-bottom: var(--sp-4); +} + +.confirm-danger { + background: var(--error-bg); + border: 1px solid color-mix(in srgb, var(--error-fg) 20%, transparent); + border-radius: var(--radius); + padding: var(--sp-4); + margin-bottom: var(--sp-4); +} + +@media (prefers-color-scheme: dark) { + .status-active { + background: var(--success-bg); + color: var(--success-fg); + } + + .status-inactive { + background: var(--error-bg); + color: var(--error-fg); + } +} + /* ---------- Typography ---------- */ h1 { diff --git a/src/porchlight/templates/admin/base.html b/src/porchlight/templates/admin/base.html new file mode 100644 index 0000000..d838751 --- /dev/null +++ b/src/porchlight/templates/admin/base.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block content %} + +{% block admin_content %}{% endblock %} +{% endblock %} From 1a795914f9ff793e6217ce966fe6ae333b436b01 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 11:35:25 +0100 Subject: [PATCH 04/11] feat: add admin user list page with search and pagination --- src/porchlight/admin/routes.py | 30 +++++++++++- .../templates/admin/_pagination.html | 13 +++++ .../templates/admin/_user_rows.html | 21 ++++++++ src/porchlight/templates/admin/users.html | 49 +++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/porchlight/templates/admin/_pagination.html create mode 100644 src/porchlight/templates/admin/_user_rows.html create mode 100644 src/porchlight/templates/admin/users.html diff --git a/src/porchlight/admin/routes.py b/src/porchlight/admin/routes.py index 78500c4..a8423b0 100644 --- a/src/porchlight/admin/routes.py +++ b/src/porchlight/admin/routes.py @@ -30,5 +30,31 @@ async def users_list(request: Request) -> Response: if admin is None: return HTMLResponse("Forbidden", status_code=403) - # Placeholder — will be implemented in Task 4 - return HTMLResponse("Admin users list") + per_page = 20 + q = request.query_params.get("q", "") + offset = int(request.query_params.get("offset", "0")) + + user_repo = request.app.state.user_repo + if q: + users = await user_repo.search_users(q, offset, per_page) + total = await user_repo.count_users(query=q) + else: + users = await user_repo.list_users(offset, per_page) + total = await user_repo.count_users() + + context = { + "users": users, + "query": q, + "offset": offset, + "per_page": per_page, + "total": total, + "active_page": "users", + } + + # HTMX search requests get just the table rows partial + if request.headers.get("HX-Request") and request.headers.get("HX-Trigger-Name") == "q": + templates = request.app.state.templates + return templates.TemplateResponse(request, "admin/_user_rows.html", context) + + templates = request.app.state.templates + return templates.TemplateResponse(request, "admin/users.html", context) diff --git a/src/porchlight/templates/admin/_pagination.html b/src/porchlight/templates/admin/_pagination.html new file mode 100644 index 0000000..088724f --- /dev/null +++ b/src/porchlight/templates/admin/_pagination.html @@ -0,0 +1,13 @@ +{% if total > 0 %} + + Showing {{ offset + 1 }}–{{ offset + users|length }} of {{ total }} + +{% endif %} + + {% if offset > 0 %} + Previous + {% endif %} + {% if offset + per_page < total %} + Next + {% endif %} + diff --git a/src/porchlight/templates/admin/_user_rows.html b/src/porchlight/templates/admin/_user_rows.html new file mode 100644 index 0000000..48ab3cb --- /dev/null +++ b/src/porchlight/templates/admin/_user_rows.html @@ -0,0 +1,21 @@ +{% for user in users %} + + {{ user.username }} + {{ [user.given_name, user.family_name]|select|join(' ') }} + {{ user.email or '' }} + {% for g in user.groups %}{{ g }} {% endfor %} + + + {% if user.active %} + Active + {% else %} + Inactive + {% endif %} + + + {{ user.created_at.strftime('%Y-%m-%d') }} + +{% endfor %} +{% if not users %} +No users found. +{% endif %} diff --git a/src/porchlight/templates/admin/users.html b/src/porchlight/templates/admin/users.html new file mode 100644 index 0000000..26d660b --- /dev/null +++ b/src/porchlight/templates/admin/users.html @@ -0,0 +1,49 @@ +{% extends "admin/base.html" %} + +{% block title %}Users — Admin — Porchlight{% endblock %} + +{% block admin_content %} +

Users

+ +
+

Create invite

+
+ +
+
+
+ +
+ + +
+ + + + + + + + + + + + + {% include "admin/_user_rows.html" %} + +
UsernameNameEmailGroupsStatusCreated
+ +
+
+{% endblock %} From 6a9e32f74dad37cfb06000fc98e6171eebb5ea5a Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 13:36:11 +0100 Subject: [PATCH 05/11] feat: add admin invite creation endpoint --- src/porchlight/admin/routes.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/porchlight/admin/routes.py b/src/porchlight/admin/routes.py index a8423b0..57809f5 100644 --- a/src/porchlight/admin/routes.py +++ b/src/porchlight/admin/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Request, Response +from fastapi import APIRouter, Form, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from porchlight.dependencies import get_session_user @@ -58,3 +58,30 @@ async def users_list(request: Request) -> Response: templates = request.app.state.templates return templates.TemplateResponse(request, "admin/users.html", context) + + +@router.post("/invite", response_class=HTMLResponse) +async def create_invite( + request: Request, + username: str = Form(), +) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + username = username.strip() + if not username: + return HTMLResponse('
Username is required
') + + magic_link_service = request.app.state.magic_link_service + settings = request.app.state.settings + link = await magic_link_service.create(username=username, created_by=admin.username, note="admin invite") + url = f"{settings.issuer}/register/{link.token}" + + return HTMLResponse( + f'
Invite created for {username}:
{url}
' + ) From 2b8d3e98002c097e5f383e33e791145598cc5eb8 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 13:44:14 +0100 Subject: [PATCH 06/11] feat: add admin user detail page with profile, groups, credentials, and actions --- src/porchlight/admin/routes.py | 32 +++++ src/porchlight/app.py | 2 + .../templates/admin/user_detail.html | 124 ++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/porchlight/templates/admin/user_detail.html diff --git a/src/porchlight/admin/routes.py b/src/porchlight/admin/routes.py index 57809f5..634cd17 100644 --- a/src/porchlight/admin/routes.py +++ b/src/porchlight/admin/routes.py @@ -60,6 +60,38 @@ async def users_list(request: Request) -> Response: return templates.TemplateResponse(request, "admin/users.html", context) +@router.get("/users/{userid}", response_class=HTMLResponse) +async def user_detail(request: Request, userid: str) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + user_repo = request.app.state.user_repo + cred_repo = request.app.state.credential_repo + target_user = await user_repo.get_by_userid(userid) + if target_user is None: + return HTMLResponse("User not found", status_code=404) + + webauthn_credentials = await cred_repo.get_webauthn_by_user(userid) + password_credential = await cred_repo.get_password_by_user(userid) + + templates = request.app.state.templates + return templates.TemplateResponse( + request, + "admin/user_detail.html", + { + "target_user": target_user, + "webauthn_credentials": webauthn_credentials, + "has_password": password_credential is not None, + "active_page": "users", + }, + ) + + @router.post("/invite", response_class=HTMLResponse) async def create_invite( request: Request, diff --git a/src/porchlight/app.py b/src/porchlight/app.py index a27fc1c..d5ad11c 100644 --- a/src/porchlight/app.py +++ b/src/porchlight/app.py @@ -1,4 +1,5 @@ import secrets +from base64 import urlsafe_b64encode from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path @@ -110,6 +111,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: # Templates app.state.templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates")) + app.state.templates.env.filters["b64encode"] = lambda v: urlsafe_b64encode(v).decode().rstrip("=") # Static files app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static") diff --git a/src/porchlight/templates/admin/user_detail.html b/src/porchlight/templates/admin/user_detail.html new file mode 100644 index 0000000..147adc7 --- /dev/null +++ b/src/porchlight/templates/admin/user_detail.html @@ -0,0 +1,124 @@ +{% extends "admin/base.html" %} + +{% block title %}{{ target_user.username }} — Admin — Porchlight{% endblock %} + +{% block admin_content %} +

{{ target_user.username }}

+

ID: {{ target_user.userid }} · Created {{ target_user.created_at.strftime('%Y-%m-%d %H:%M') }}

+ +
+ +
+

Profile

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Groups

+
+
+
+ {% for group in target_user.groups %} + {{ group }} + {% endfor %} +
+
+ + +
+
+ +
+
+
+ +
+

Credentials

+
+

Password

+ {% if has_password %} +

Password is set. + +

+ {% else %} +

No password set.

+ {% endif %} + +

Security keys

+ {% if webauthn_credentials %} +
    + {% for cred in webauthn_credentials %} +
  • + {{ cred.device_name or "Security key" }} + (added {{ cred.created_at.strftime('%Y-%m-%d') }}) + +
  • + {% endfor %} +
+ {% else %} +

No security keys registered.

+ {% endif %} +
+
+ +
+

Actions

+
+
+ {% if target_user.active %} + + {% else %} + + {% endif %} +
+
+ +
+
+
+ +
+
+
+ +
+{% endblock %} From 3975d5ce885247c8927b20bcfa234b342460718d Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 13:47:36 +0100 Subject: [PATCH 07/11] feat: add admin action routes (profile, groups, activate, credentials, invite, delete) --- src/porchlight/admin/routes.py | 229 ++++++++++++++++++ .../templates/admin/_credentials_section.html | 27 +++ 2 files changed, 256 insertions(+) create mode 100644 src/porchlight/templates/admin/_credentials_section.html diff --git a/src/porchlight/admin/routes.py b/src/porchlight/admin/routes.py index 634cd17..44283d6 100644 --- a/src/porchlight/admin/routes.py +++ b/src/porchlight/admin/routes.py @@ -1,3 +1,6 @@ +from base64 import urlsafe_b64decode +from urllib.parse import urlparse + from fastapi import APIRouter, Form, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse @@ -117,3 +120,229 @@ async def create_invite( return HTMLResponse( f'
Invite created for {username}:
{url}
' ) + + +# --- Profile update --- +@router.post("/users/{userid}/profile", response_class=HTMLResponse) +async def update_user_profile( + request: Request, + userid: str, + 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) + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + # Validate + if email and "@" not in email: + return HTMLResponse('
Invalid email address
') + 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) + if user is None: + return HTMLResponse("User not found", status_code=404) + + 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
') + + +# --- Groups update --- +@router.post("/users/{userid}/groups", response_class=HTMLResponse) +async def update_user_groups( + request: Request, + userid: str, + groups: str = Form(""), +) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + user_repo = request.app.state.user_repo + user = await user_repo.get_by_userid(userid) + if user is None: + return HTMLResponse("User not found", status_code=404) + + group_list = [g.strip() for g in groups.split(",") if g.strip()] + updated = user.model_copy(update={"groups": group_list}) + await user_repo.update(updated) + return HTMLResponse('
Groups updated
') + + +# --- Activate / deactivate --- +@router.post("/users/{userid}/activate", response_class=HTMLResponse) +async def activate_user(request: Request, userid: str) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + user_repo = request.app.state.user_repo + user = await user_repo.get_by_userid(userid) + if user is None: + return HTMLResponse("User not found", status_code=404) + + updated = user.model_copy(update={"active": True}) + await user_repo.update(updated) + return HTMLResponse( + '
User activated
' + f'' + ) + + +@router.post("/users/{userid}/deactivate", response_class=HTMLResponse) +async def deactivate_user(request: Request, userid: str) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + user_repo = request.app.state.user_repo + user = await user_repo.get_by_userid(userid) + if user is None: + return HTMLResponse("User not found", status_code=404) + + updated = user.model_copy(update={"active": False}) + await user_repo.update(updated) + return HTMLResponse( + '
User deactivated
' + f'' + ) + + +# --- Delete credentials --- +@router.delete("/users/{userid}/credentials/password", response_class=HTMLResponse) +async def delete_user_password(request: Request, userid: str) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + cred_repo = request.app.state.credential_repo + await cred_repo.delete_password(userid) + + # Re-render credentials section + webauthn_credentials = await cred_repo.get_webauthn_by_user(userid) + templates = request.app.state.templates + return templates.TemplateResponse( + request, + "admin/_credentials_section.html", + { + "target_user": await request.app.state.user_repo.get_by_userid(userid), + "webauthn_credentials": webauthn_credentials, + "has_password": False, + }, + ) + + +@router.delete("/users/{userid}/credentials/webauthn/{credential_id_b64}", response_class=HTMLResponse) +async def delete_user_webauthn(request: Request, userid: str, credential_id_b64: str) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + padded = credential_id_b64 + "=" * (-len(credential_id_b64) % 4) + credential_id = urlsafe_b64decode(padded) + cred_repo = request.app.state.credential_repo + await cred_repo.delete_webauthn(userid, credential_id) + + # Re-render credentials section + webauthn_credentials = await cred_repo.get_webauthn_by_user(userid) + password = await cred_repo.get_password_by_user(userid) + templates = request.app.state.templates + return templates.TemplateResponse( + request, + "admin/_credentials_section.html", + { + "target_user": await request.app.state.user_repo.get_by_userid(userid), + "webauthn_credentials": webauthn_credentials, + "has_password": password is not None, + }, + ) + + +# --- Re-invite --- +@router.post("/users/{userid}/invite", response_class=HTMLResponse) +async def reinvite_user(request: Request, userid: str) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + user_repo = request.app.state.user_repo + user = await user_repo.get_by_userid(userid) + if user is None: + return HTMLResponse("User not found", status_code=404) + + magic_link_service = request.app.state.magic_link_service + settings = request.app.state.settings + link = await magic_link_service.create(username=user.username, created_by=admin.username, note="admin re-invite") + url = f"{settings.issuer}/register/{link.token}" + + return HTMLResponse(f'
Invite link generated:
{url}
') + + +# --- Delete user --- +@router.delete("/users/{userid}", response_class=HTMLResponse) +async def delete_user(request: Request, userid: str) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + # Prevent self-deletion + admin_userid, _ = get_session_user(request) + if userid == admin_userid: + return HTMLResponse('
Cannot delete your own account
') + + user_repo = request.app.state.user_repo + deleted = await user_repo.delete(userid) + if not deleted: + return HTMLResponse("User not found", status_code=404) + + return HTMLResponse( + status_code=200, + content='
User deleted
', + headers={"HX-Redirect": "/admin/users"}, + ) diff --git a/src/porchlight/templates/admin/_credentials_section.html b/src/porchlight/templates/admin/_credentials_section.html new file mode 100644 index 0000000..b2351eb --- /dev/null +++ b/src/porchlight/templates/admin/_credentials_section.html @@ -0,0 +1,27 @@ +

Password

+{% if has_password %} +

Password is set. + +

+{% else %} +

No password set.

+{% endif %} + +

Security keys

+{% if webauthn_credentials %} +
    + {% for cred in webauthn_credentials %} +
  • + {{ cred.device_name or "Security key" }} + (added {{ cred.created_at.strftime('%Y-%m-%d') }}) + +
  • + {% endfor %} +
+{% else %} +

No security keys registered.

+{% endif %} From 186be17b970db09572c131557f579fc002ec4adf Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 14:01:39 +0100 Subject: [PATCH 08/11] test: add comprehensive unit tests for admin routes --- tests/test_admin/test_admin_routes.py | 391 ++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 tests/test_admin/test_admin_routes.py diff --git a/tests/test_admin/test_admin_routes.py b/tests/test_admin/test_admin_routes.py new file mode 100644 index 0000000..53e0591 --- /dev/null +++ b/tests/test_admin/test_admin_routes.py @@ -0,0 +1,391 @@ +from datetime import UTC, datetime + +import pytest +from argon2 import PasswordHasher +from httpx import AsyncClient + +from porchlight.authn.password import PasswordService +from porchlight.models import PasswordCredential, User, WebAuthnCredential + + +async def _login( + client: AsyncClient, + username: str = "admin", + password: str = "adminpass", + *, + userid: str = "admin-user-01", + groups: list[str] | None = None, +) -> None: + """Helper: create user + password credential and log in via POST /login/password.""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = await user_repo.get_by_username(username) + if user is None: + user = User( + userid=userid, + username=username, + groups=groups if groups is not None else ["admin"], + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + existing = await cred_repo.get_password_by_user(user.userid) + if existing is None: + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password))) + + await client.post( + "/login/password", + data={"username": username, "password": password}, + headers={"HX-Request": "true"}, + ) + + +async def _create_target_user( + client: AsyncClient, + *, + userid: str = "target-user-01", + username: str = "bob", + email: str | None = None, + groups: list[str] | None = None, + active: bool = True, +) -> User: + """Helper: create a target user in the database (does NOT log in).""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + user = User( + userid=userid, + username=username, + email=email, + groups=groups or [], + active=active, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + return await user_repo.create(user) + + +# --- User list --- + + +@pytest.mark.asyncio +async def test_users_list_returns_html(client: AsyncClient) -> None: + await _login(client) + response = await client.get("/admin/users") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +@pytest.mark.asyncio +async def test_users_list_shows_users(client: AsyncClient) -> None: + await _login(client) + await _create_target_user(client, username="bob") + response = await client.get("/admin/users") + assert response.status_code == 200 + assert "bob" in response.text + assert "admin" in response.text + + +@pytest.mark.asyncio +async def test_users_list_search(client: AsyncClient) -> None: + await _login(client) + await _create_target_user(client, userid="target-user-01", username="bob") + await _create_target_user(client, userid="target-user-02", username="carol") + response = await client.get("/admin/users?q=bob") + assert response.status_code == 200 + assert "bob" in response.text + assert "carol" not in response.text + + +@pytest.mark.asyncio +async def test_users_list_htmx_search_returns_partial(client: AsyncClient) -> None: + await _login(client) + await _create_target_user(client, username="bob") + response = await client.get( + "/admin/users?q=bob", + headers={"HX-Request": "true", "HX-Trigger-Name": "q"}, + ) + assert response.status_code == 200 + # Partial should contain user row data but not the full page layout + assert "bob" in response.text + assert "" not in response.text + assert " None: + await _login(client) + target = await _create_target_user(client, username="bob") + response = await client.get(f"/admin/users/{target.userid}") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "bob" in response.text + + +@pytest.mark.asyncio +async def test_user_detail_404_for_nonexistent(client: AsyncClient) -> None: + await _login(client) + response = await client.get("/admin/users/nonexistent-id") + assert response.status_code == 404 + assert "not found" in response.text.lower() + + +# --- Profile update --- + + +@pytest.mark.asyncio +async def test_update_profile(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob") + response = await client.post( + f"/admin/users/{target.userid}/profile", + data={ + "given_name": "Bob", + "family_name": "Smith", + "preferred_username": "bobby", + "email": "bob@example.com", + "phone_number": "+1234567890", + "picture": "https://example.com/bob.jpg", + "locale": "en-US", + }, + ) + assert response.status_code == 200 + assert "Profile updated" in response.text + + # Verify user was actually updated + app = client._transport.app # type: ignore[union-attr] + user = await app.state.user_repo.get_by_userid(target.userid) + assert user is not None + assert user.given_name == "Bob" + assert user.family_name == "Smith" + assert user.email == "bob@example.com" + assert user.picture == "https://example.com/bob.jpg" + + +@pytest.mark.asyncio +async def test_update_profile_invalid_email(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob") + response = await client.post( + f"/admin/users/{target.userid}/profile", + data={"email": "not-an-email"}, + ) + assert response.status_code == 200 + assert "Invalid email" in response.text + + +@pytest.mark.asyncio +async def test_update_profile_invalid_picture_url(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob") + response = await client.post( + f"/admin/users/{target.userid}/profile", + data={"picture": "not-a-url"}, + ) + assert response.status_code == 200 + assert "Picture URL must be a valid HTTP or HTTPS URL" in response.text + + +@pytest.mark.asyncio +async def test_update_profile_404_for_nonexistent(client: AsyncClient) -> None: + await _login(client) + response = await client.post( + "/admin/users/nonexistent-id/profile", + data={"given_name": "Ghost"}, + ) + assert response.status_code == 404 + assert "not found" in response.text.lower() + + +# --- Groups update --- + + +@pytest.mark.asyncio +async def test_update_groups(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob") + response = await client.post( + f"/admin/users/{target.userid}/groups", + data={"groups": "admin, users, editors"}, + ) + assert response.status_code == 200 + assert "Groups updated" in response.text + + # Verify groups were actually updated + app = client._transport.app # type: ignore[union-attr] + user = await app.state.user_repo.get_by_userid(target.userid) + assert user is not None + assert set(user.groups) == {"admin", "users", "editors"} + + +# --- Activate / deactivate --- + + +@pytest.mark.asyncio +async def test_activate_user(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob", active=False) + response = await client.post(f"/admin/users/{target.userid}/activate") + assert response.status_code == 200 + assert "User activated" in response.text + + app = client._transport.app # type: ignore[union-attr] + user = await app.state.user_repo.get_by_userid(target.userid) + assert user is not None + assert user.active is True + + +@pytest.mark.asyncio +async def test_deactivate_user(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob", active=True) + response = await client.post(f"/admin/users/{target.userid}/deactivate") + assert response.status_code == 200 + assert "User deactivated" in response.text + + app = client._transport.app # type: ignore[union-attr] + user = await app.state.user_repo.get_by_userid(target.userid) + assert user is not None + assert user.active is False + + +# --- Delete user --- + + +@pytest.mark.asyncio +async def test_delete_user(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob") + response = await client.request("DELETE", f"/admin/users/{target.userid}") + assert response.status_code == 200 + assert "User deleted" in response.text + assert response.headers.get("hx-redirect") == "/admin/users" + + # Verify user was actually deleted + app = client._transport.app # type: ignore[union-attr] + user = await app.state.user_repo.get_by_userid(target.userid) + assert user is None + + +@pytest.mark.asyncio +async def test_delete_user_self_prevention(client: AsyncClient) -> None: + await _login(client, userid="admin-user-01") + # Try to delete ourselves + response = await client.request("DELETE", "/admin/users/admin-user-01") + assert response.status_code == 200 + assert "Cannot delete your own account" in response.text + + # Verify admin user was NOT deleted + app = client._transport.app # type: ignore[union-attr] + user = await app.state.user_repo.get_by_userid("admin-user-01") + assert user is not None + + +# --- Delete credentials --- + + +@pytest.mark.asyncio +async def test_delete_password_credential(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob") + + # Create a password credential for the target user + app = client._transport.app # type: ignore[union-attr] + cred_repo = app.state.credential_repo + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=target.userid, password_hash=svc.hash("bobpass"))) + + response = await client.request("DELETE", f"/admin/users/{target.userid}/credentials/password") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + # Verify password was deleted + pw = await cred_repo.get_password_by_user(target.userid) + assert pw is None + + +@pytest.mark.asyncio +async def test_delete_webauthn_credential(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob") + + # Create a webauthn credential for the target user + app = client._transport.app # type: ignore[union-attr] + cred_repo = app.state.credential_repo + credential_id = b"\x01\x02\x03\x04\x05\x06\x07\x08" + await cred_repo.create_webauthn( + WebAuthnCredential( + user_id=target.userid, + credential_id=credential_id, + public_key=b"\x00" * 32, + sign_count=0, + device_name="test-key", + ) + ) + + # URL uses base64url without padding + from base64 import urlsafe_b64encode + + credential_id_b64 = urlsafe_b64encode(credential_id).decode().rstrip("=") + + response = await client.request("DELETE", f"/admin/users/{target.userid}/credentials/webauthn/{credential_id_b64}") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + # Verify credential was deleted + creds = await cred_repo.get_webauthn_by_user(target.userid) + assert len(creds) == 0 + + +# --- Create invite --- + + +@pytest.mark.asyncio +async def test_create_invite(client: AsyncClient) -> None: + await _login(client) + response = await client.post( + "/admin/invite", + data={"username": "newuser"}, + ) + assert response.status_code == 200 + assert "Invite created" in response.text + assert "newuser" in response.text + assert "/register/" in response.text + + +@pytest.mark.asyncio +async def test_create_invite_empty_username(client: AsyncClient) -> None: + await _login(client) + response = await client.post( + "/admin/invite", + data={"username": " "}, + ) + assert response.status_code == 200 + assert "Username is required" in response.text + + +# --- Re-invite --- + + +@pytest.mark.asyncio +async def test_reinvite_user(client: AsyncClient) -> None: + await _login(client) + target = await _create_target_user(client, username="bob") + response = await client.post(f"/admin/users/{target.userid}/invite") + assert response.status_code == 200 + assert "Invite link generated" in response.text + assert "/register/" in response.text + + +@pytest.mark.asyncio +async def test_reinvite_user_404_for_nonexistent(client: AsyncClient) -> None: + await _login(client) + response = await client.post("/admin/users/nonexistent-id/invite") + assert response.status_code == 404 + assert "not found" in response.text.lower() From 7ad794170d2d2b8940302c6f7e67009c2de29f60 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 14:20:57 +0100 Subject: [PATCH 09/11] feat: show admin link in manage nav for admin users --- src/porchlight/manage/routes.py | 6 ++++++ src/porchlight/templates/manage/base.html | 1 + 2 files changed, 7 insertions(+) diff --git a/src/porchlight/manage/routes.py b/src/porchlight/manage/routes.py index 9b9c82e..18358e8 100644 --- a/src/porchlight/manage/routes.py +++ b/src/porchlight/manage/routes.py @@ -26,10 +26,13 @@ async def credentials_page(request: Request) -> Response: userid, username = session_user cred_repo = request.app.state.credential_repo + user_repo = request.app.state.user_repo webauthn_credentials = await cred_repo.get_webauthn_by_user(userid) password_credential = await cred_repo.get_password_by_user(userid) setup = request.query_params.get("setup") + user = await user_repo.get_by_userid(userid) + is_admin = user is not None and "admin" in user.groups templates = request.app.state.templates return templates.TemplateResponse( @@ -41,6 +44,7 @@ async def credentials_page(request: Request) -> Response: "has_password": password_credential is not None, "setup": setup, "active_page": "credentials", + "is_admin": is_admin, }, ) @@ -177,6 +181,7 @@ async def profile_page(request: Request) -> Response: userid, username = session_user user_repo = request.app.state.user_repo user = await user_repo.get_by_userid(userid) + is_admin = user is not None and "admin" in user.groups templates = request.app.state.templates return templates.TemplateResponse( @@ -186,6 +191,7 @@ async def profile_page(request: Request) -> Response: "username": username, "user": user, "active_page": "profile", + "is_admin": is_admin, }, ) diff --git a/src/porchlight/templates/manage/base.html b/src/porchlight/templates/manage/base.html index 15166e6..7096033 100644 --- a/src/porchlight/templates/manage/base.html +++ b/src/porchlight/templates/manage/base.html @@ -4,6 +4,7 @@ {% block manage_content %}{% endblock %} {% endblock %} From 5a24a9c70b5ae1be473d3b390e45f0a7736281d2 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 14:31:41 +0100 Subject: [PATCH 10/11] test: add E2E tests for admin pages --- tests/e2e/admin.spec.js | 238 ++++++++++++++++++++++++++++++++++++++++ tests/e2e/setup_db.py | 22 ++++ 2 files changed, 260 insertions(+) create mode 100644 tests/e2e/admin.spec.js diff --git a/tests/e2e/admin.spec.js b/tests/e2e/admin.spec.js new file mode 100644 index 0000000..24971a7 --- /dev/null +++ b/tests/e2e/admin.spec.js @@ -0,0 +1,238 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); + +/** Log in as the admin user and land on /manage/credentials. */ +async function loginAsAdmin(page) { + await page.goto('/login'); + await page.fill('#username', fixtures.admin_username); + await page.fill('#password', fixtures.admin_password); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + await page.waitForURL('**/manage/credentials', { timeout: 5000 }); +} + +/** Log in as a regular (non-admin) user. */ +async function loginAsRegularUser(page) { + await page.goto('/login'); + await page.fill('#username', fixtures.login_username); + await page.fill('#password', fixtures.login_password); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + await page.waitForURL('**/manage/credentials', { timeout: 5000 }); +} + +test.describe('Admin pages', () => { + // --------------------------------------------------------------- + // 1 & 2. Auth guards + // --------------------------------------------------------------- + test.describe('Auth guard', () => { + test('unauthenticated user visiting /admin/users is redirected to /login', async ({ page }) => { + await page.goto('/admin/users'); + await page.waitForURL('**/login', { timeout: 5000 }); + expect(page.url()).toContain('/login'); + }); + + test('non-admin logged-in user gets 403', async ({ page }) => { + await loginAsRegularUser(page); + const response = await page.goto('/admin/users'); + expect(response.status()).toBe(403); + }); + }); + + // --------------------------------------------------------------- + // 3 & 4 & 5. User list page + // --------------------------------------------------------------- + test.describe('User list page', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/users'); + }); + + test('has correct page structure', async ({ page }) => { + await expect(page.locator('h1')).toHaveText('Users'); + await expect(page.locator('.admin-table thead th')).toHaveCount(6); + await expect(page.locator('input[name="q"]')).toBeVisible(); + await expect(page.locator('form[hx-post="/admin/invite"]')).toBeVisible(); + }); + + test('table headers are correct', async ({ page }) => { + const headers = page.locator('.admin-table thead th'); + await expect(headers.nth(0)).toHaveText('Username'); + await expect(headers.nth(1)).toHaveText('Name'); + await expect(headers.nth(2)).toHaveText('Email'); + await expect(headers.nth(3)).toHaveText('Groups'); + await expect(headers.nth(4)).toHaveText('Status'); + await expect(headers.nth(5)).toHaveText('Created'); + }); + + test('shows seeded users', async ({ page }) => { + const tableBody = page.locator('#user-table-body'); + await expect(tableBody).toContainText('testuser'); + await expect(tableBody).toContainText('adminuser'); + }); + + test('search filters results', async ({ page }) => { + const searchInput = page.locator('input[name="q"]'); + await searchInput.fill('admin'); + // Wait for htmx debounce (300ms) and response + await expect(page.locator('#user-table-body')).toContainText('adminuser', { timeout: 5000 }); + // Other users should be filtered out + await expect(page.locator('#user-table-body')).not.toContainText('testuser', { timeout: 3000 }); + }); + }); + + // --------------------------------------------------------------- + // 6. User detail page structure + // --------------------------------------------------------------- + test.describe('User detail page', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/users'); + }); + + test('clicking a user link shows detail page', async ({ page }) => { + await page.click(`a:has-text("adminuser")`); + await page.waitForURL('**/admin/users/**', { timeout: 5000 }); + + await expect(page.locator('h1')).toHaveText('adminuser'); + // Profile section + await expect(page.locator('h2:has-text("Profile")')).toBeVisible(); + await expect(page.locator('#given_name')).toBeVisible(); + // Groups section + await expect(page.locator('h2:has-text("Groups")')).toBeVisible(); + await expect(page.locator('#groups')).toBeVisible(); + // Credentials section + await expect(page.locator('h2:has-text("Credentials")')).toBeVisible(); + // Actions section + await expect(page.locator('h2:has-text("Actions")')).toBeVisible(); + }); + }); + + // --------------------------------------------------------------- + // 7. Profile update + // --------------------------------------------------------------- + test.describe('Profile update', () => { + test('fill in profile fields and save', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`/admin/users/${fixtures.admin_userid}`); + + await page.fill('#given_name', 'Updated'); + await page.fill('#family_name', 'Name'); + await page.fill('#email', 'updated@example.com'); + + await page.click('section:has(h2:has-text("Profile")) button[type="submit"]'); + + const status = page.locator('#profile-status [role="status"]'); + await expect(status).toBeVisible({ timeout: 5000 }); + await expect(status).toContainText('Profile updated'); + }); + }); + + // --------------------------------------------------------------- + // 8. Groups update + // --------------------------------------------------------------- + test.describe('Groups update', () => { + test('change groups and save', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`/admin/users/${fixtures.admin_userid}`); + + await page.fill('#groups', 'admin, users, editors'); + await page.click('section:has(h2:has-text("Groups")) button[type="submit"]'); + + const status = page.locator('#groups-status [role="status"]'); + await expect(status).toBeVisible({ timeout: 5000 }); + await expect(status).toContainText('Groups updated'); + }); + }); + + // --------------------------------------------------------------- + // 9. Activate/deactivate toggle + // --------------------------------------------------------------- + test.describe('Activate/deactivate toggle', () => { + test('deactivate then activate user', async ({ page }) => { + await loginAsAdmin(page); + // Use a non-admin user for this test + await page.goto(`/admin/users/${fixtures.admin_userid}`); + + // Click deactivate + await page.click('button:has-text("Deactivate user")'); + const deactivatedStatus = page.locator('#actions-section [role="status"]'); + await expect(deactivatedStatus).toBeVisible({ timeout: 5000 }); + await expect(deactivatedStatus).toContainText('User deactivated'); + + // Now an activate button should appear + await expect(page.locator('button:has-text("Activate user")')).toBeVisible(); + + // Click activate + await page.click('button:has-text("Activate user")'); + const activatedStatus = page.locator('#actions-section [role="status"]'); + await expect(activatedStatus).toBeVisible({ timeout: 5000 }); + await expect(activatedStatus).toContainText('User activated'); + + // Deactivate button should reappear + await expect(page.locator('button:has-text("Deactivate user")')).toBeVisible(); + }); + }); + + // --------------------------------------------------------------- + // 10. Create invite from user list + // --------------------------------------------------------------- + test.describe('Create invite', () => { + test('fill username and submit to get invite URL', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/users'); + + await page.fill('form[hx-post="/admin/invite"] input[name="username"]', 'inviteduser'); + await page.click('form[hx-post="/admin/invite"] button[type="submit"]'); + + const inviteStatus = page.locator('#invite-status'); + await expect(inviteStatus.locator('.invite-url')).toBeVisible({ timeout: 5000 }); + const inviteUrl = await inviteStatus.locator('.invite-url').textContent(); + expect(inviteUrl).toContain('/register/'); + }); + }); + + // --------------------------------------------------------------- + // 11. Re-invite from user detail + // --------------------------------------------------------------- + test.describe('Re-invite from user detail', () => { + test('generate invite link from user detail page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`/admin/users/${fixtures.admin_userid}`); + + await page.click('button:has-text("Generate invite link")'); + + const inviteResult = page.locator('#invite-result'); + await expect(inviteResult.locator('.invite-url')).toBeVisible({ timeout: 5000 }); + const inviteUrl = await inviteResult.locator('.invite-url').textContent(); + expect(inviteUrl).toContain('/register/'); + }); + }); + + // --------------------------------------------------------------- + // 12. Delete user + // --------------------------------------------------------------- + test.describe('Delete user', () => { + test('delete a user and verify redirect to user list', async ({ page }) => { + await loginAsAdmin(page); + + // Use a disposable user seeded specifically for this test + await page.goto(`/admin/users/${fixtures.disposable_userid}`); + await expect(page.locator('h1')).toHaveText('disposableuser'); + + // Set up dialog handler before clicking delete + page.on('dialog', async (dialog) => { + await dialog.accept(); + }); + + await page.click('button:has-text("Delete user")'); + + // htmx processes the HX-Redirect header and navigates to /admin/users + await page.waitForURL('**/admin/users', { timeout: 5000 }); + await expect(page.locator('h1')).toHaveText('Users'); + + // The deleted user should no longer appear in the list + await expect(page.locator('#user-table-body')).not.toContainText('disposableuser'); + }); + }); +}); diff --git a/tests/e2e/setup_db.py b/tests/e2e/setup_db.py index b654054..bce3821 100644 --- a/tests/e2e/setup_db.py +++ b/tests/e2e/setup_db.py @@ -100,6 +100,28 @@ async def seed() -> None: result["profile_username"] = "profileuser" result["profile_password"] = "profilepass123" + # 6. Admin user for admin page tests + admin_user = User( + userid="test-user-05", + username="adminuser", + given_name="Admin", + family_name="User", + email="admin@example.com", + groups=["admin", "users"], + ) + await user_repo.create(admin_user) + admin_password_hash = password_service.hash("adminpass123") + await cred_repo.create_password(PasswordCredential(user_id=admin_user.userid, password_hash=admin_password_hash)) + result["admin_username"] = "adminuser" + result["admin_password"] = "adminpass123" + result["admin_userid"] = "test-user-05" + + # 7. Disposable user for admin delete test (not used by any other tests) + disposable_user = User(userid="test-user-06", username="disposableuser", groups=["users"]) + await user_repo.create(disposable_user) + result["disposable_userid"] = "test-user-06" + result["disposable_username"] = "disposableuser" + await db.commit() await db.close() print(json.dumps(result)) From 34450aa38ff090a5a6d7117f320d7f6120936ee1 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 14:33:57 +0100 Subject: [PATCH 11/11] style: fix import sort order in app.py --- src/porchlight/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/porchlight/app.py b/src/porchlight/app.py index d5ad11c..c9baec6 100644 --- a/src/porchlight/app.py +++ b/src/porchlight/app.py @@ -10,8 +10,8 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware -from porchlight.authn.password import PasswordService from porchlight.admin.routes import router as admin_router +from porchlight.authn.password import PasswordService from porchlight.authn.routes import router as authn_router from porchlight.authn.webauthn import WebAuthnService from porchlight.config import Settings, StorageBackend