# Admin Pages Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build admin pages at `/admin/` for user management -- list, search, view, edit, activate/deactivate, delete users, manage groups, view/delete credentials, and create/re-send invite links. **Architecture:** New `src/porchlight/admin/` package with its own router mounted at `/admin/`. Admin guard checks `"admin" in user.groups`. Templates under `templates/admin/`. HTMX for all interactive actions. Adds `search_users()` and `count_users()` to the repository layer. **Tech Stack:** FastAPI, Jinja2, htmx, aiosqlite, Playwright (E2E tests), pytest (unit tests) --- ### Task 1: Repository — add `search_users()` and `count_users()` **Files:** - Modify: `src/porchlight/store/protocols.py` - Modify: `src/porchlight/store/sqlite/repositories.py` - Test: `tests/test_store/test_sqlite_user_repo.py` **Step 1: Write failing tests** Add to `tests/test_store/test_sqlite_user_repo.py`: ```python async def test_search_users_by_username(user_repo, sample_user): await user_repo.create(sample_user) results = await user_repo.search_users("sample", offset=0, limit=100) assert len(results) == 1 assert results[0].userid == sample_user.userid async def test_search_users_by_email(user_repo, sample_user): user = sample_user.model_copy(update={"email": "alice@example.com"}) await user_repo.create(user) results = await user_repo.search_users("alice", offset=0, limit=100) assert len(results) == 1 async def test_search_users_no_match(user_repo, sample_user): await user_repo.create(sample_user) results = await user_repo.search_users("nonexistent", offset=0, limit=100) assert len(results) == 0 async def test_search_users_pagination(user_repo): for i in range(5): user = User(userid=f"id-{i}", username=f"user{i}", groups=["users"]) await user_repo.create(user) 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, sample_user): await user_repo.create(sample_user) count = await user_repo.count_users() assert count == 1 async def test_count_users_with_query(user_repo, sample_user): await user_repo.create(sample_user) count = await user_repo.count_users(query="sample") assert count == 1 count = await user_repo.count_users(query="nonexistent") assert count == 0 ``` **Step 2: Run tests to verify they fail** Run: `uv run python -m pytest tests/test_store/test_sqlite_user_repo.py -v -k "search or count_users"` Expected: FAIL — `search_users` and `count_users` not defined **Step 3: Add to protocol** In `src/porchlight/store/protocols.py`, add to `UserRepository`: ```python 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: ... ``` **Step 4: Implement in SQLite repository** In `src/porchlight/store/sqlite/repositories.py`, add to `SQLiteUserRepository`: ```python 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 ``` **Step 5: Run tests to verify they pass** Run: `uv run python -m pytest tests/test_store/test_sqlite_user_repo.py -v -k "search or count_users"` Expected: PASS **Step 6: Run full test suite** Run: `uv run python -m pytest -v` Expected: All 172+ tests pass **Step 7: Commit** ``` git add src/porchlight/store/protocols.py src/porchlight/store/sqlite/repositories.py tests/test_store/test_sqlite_user_repo.py git commit -m "feat: add search_users and count_users to user repository" ``` --- ### Task 2: Admin guard helper + admin router skeleton **Files:** - Create: `src/porchlight/admin/__init__.py` - Create: `src/porchlight/admin/routes.py` - Modify: `src/porchlight/app.py` - Test: `tests/test_admin/__init__.py` - Test: `tests/test_admin/test_admin_guard.py` **Step 1: Write failing tests** Create `tests/test_admin/__init__.py` (empty) and `tests/test_admin/test_admin_guard.py`: ```python import pytest from httpx import ASGITransport, AsyncClient from porchlight.app import create_app from porchlight.config import Settings @pytest.fixture def settings(tmp_path): return Settings( issuer="http://localhost:8000", sqlite_path=str(tmp_path / "test.db"), signing_key_path=str(tmp_path / "keys"), ) @pytest.fixture def app(settings): return create_app(settings) @pytest.fixture async def client(app): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://localhost:8000") as c: yield c @pytest.mark.asyncio async def test_admin_users_redirects_unauthenticated(client): 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, app): from porchlight.models import PasswordCredential, User async with app.router.lifespan_context(app): # Create a non-admin user user = User(userid="regular-01", username="regularuser", groups=["users"]) await app.state.user_repo.create(user) password_hash = app.state.password_service.hash("password123") await app.state.credential_repo.create_password( PasswordCredential(user_id=user.userid, password_hash=password_hash) ) # Login response = await client.post( "/login", data={"username": "regularuser", "password": "password123"}, follow_redirects=False, ) # Try to access admin response = await client.get("/admin/users", follow_redirects=False) assert response.status_code == 403 ``` **Step 2: Run tests to verify they fail** Run: `uv run python -m pytest tests/test_admin/ -v` Expected: FAIL — no `/admin/users` route **Step 3: Create admin package and router** Create `src/porchlight/admin/__init__.py` (empty). Create `src/porchlight/admin/routes.py`: ```python 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") ``` **Step 4: Mount router in app.py** In `src/porchlight/app.py`, add import: ```python from porchlight.admin.routes import router as admin_router ``` And in `create_app()`, add after the other routers: ```python app.include_router(admin_router) ``` **Step 5: Run tests to verify they pass** Run: `uv run python -m pytest tests/test_admin/ -v` Expected: PASS **Step 6: Run full test suite** Run: `uv run python -m pytest -v` Expected: All tests pass **Step 7: Commit** ``` git add src/porchlight/admin/ tests/test_admin/ src/porchlight/app.py git commit -m "feat: add admin router with admin group guard" ``` --- ### Task 3: Admin base template + CSS **Files:** - Create: `src/porchlight/templates/admin/base.html` - Modify: `src/porchlight/static/style.css` **Step 1: Create admin base template** Create `src/porchlight/templates/admin/base.html`: ```html {% extends "base.html" %} {% block content %} {% block admin_content %}{% endblock %} {% endblock %} ``` **Step 2: Add admin CSS** In `src/porchlight/static/style.css`, add (after the `.manage-nav` section): ```css /* Admin navigation */ .admin-nav { display: flex; align-items: center; gap: var(--space-md); border-bottom: 1px solid var(--border); margin-bottom: var(--space-lg); padding-bottom: var(--space-sm); } .admin-nav a { color: var(--text-muted); text-decoration: none; padding-bottom: var(--space-sm); border-bottom: 2px solid transparent; margin-bottom: -1px; } .admin-nav a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); } .admin-badge { font-size: var(--text-sm); font-weight: 600; color: var(--accent); border: 1px solid var(--accent); border-radius: 4px; padding: 0.1em 0.5em; } /* Admin table */ .admin-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); } .admin-table th, .admin-table td { text-align: left; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--border); } .admin-table th { font-weight: 600; color: var(--text-muted); } .admin-table tr:hover td { background: var(--surface); } .admin-table a { color: var(--accent); text-decoration: none; } .admin-table a:hover { text-decoration: underline; } /* Status badges */ .status-badge { font-size: var(--text-sm); font-weight: 500; padding: 0.1em 0.5em; border-radius: 4px; } .status-active { color: #15803d; background: #f0fdf4; } .status-inactive { color: #b91c1c; background: #fef2f2; } /* Group tags */ .group-tag { display: inline-flex; align-items: center; gap: 0.25em; font-size: var(--text-sm); background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 0.1em 0.5em; } .group-tag button { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 1em; line-height: 1; } .group-tag button:hover { color: var(--danger, #dc2626); } /* Search bar */ .admin-search { display: flex; gap: var(--space-sm); margin-bottom: var(--space-lg); } .admin-search input { flex: 1; } /* Pagination */ .pagination { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-lg); font-size: var(--text-sm); color: var(--text-muted); } /* Detail sections */ .admin-detail section { margin-bottom: var(--space-xl); } /* Invite result */ .invite-url { font-family: monospace; font-size: var(--text-sm); word-break: break-all; background: var(--surface); border: 1px solid var(--border); padding: var(--space-sm) var(--space-md); border-radius: 4px; } /* Confirm dialog */ .confirm-danger { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 4px; padding: var(--space-md); margin-top: var(--space-sm); } ``` Note: also add dark mode overrides for `.status-active` and `.status-inactive`: ```css /* Inside @media (prefers-color-scheme: dark) */ .status-active { color: #86efac; background: #14532d; } .status-inactive { color: #fca5a5; background: #7f1d1d; } ``` **Step 3: Commit** ``` git add src/porchlight/templates/admin/base.html src/porchlight/static/style.css git commit -m "feat: add admin base template and CSS styles" ``` --- ### Task 4: User list page (GET /admin/users) **Files:** - Create: `src/porchlight/templates/admin/users.html` - Modify: `src/porchlight/admin/routes.py` **Step 1: Create user list template** Create `src/porchlight/templates/admin/users.html`: ```html {% extends "admin/base.html" %} {% block title %}Users — Admin — Porchlight{% endblock %} {% block admin_content %}
| Username | Name | Groups | Status | Created |
|---|
ID: {{ target_user.userid }} · Created {{ target_user.created_at.strftime('%Y-%m-%d %H:%M') }}
Password is set.
{% else %}No password set.
{% endif %}No security keys registered.
{% endif %}Password is set.
{% else %}No password set.
{% endif %}No security keys registered.
{% endif %} ``` **Step 3: Run full test suite** Run: `uv run python -m pytest -v` Expected: All tests pass **Step 4: Commit** ``` git add src/porchlight/admin/routes.py src/porchlight/templates/admin/ git commit -m "feat: add admin action routes (profile, groups, activate, credentials, invite, delete)" ``` --- ### Task 8: Python unit tests for admin routes **Files:** - Create: `tests/test_admin/test_admin_routes.py` **Step 1: Write comprehensive tests** Test all admin routes using the same `httpx.AsyncClient` + `ASGITransport` pattern used elsewhere in the test suite. Cover: - Admin guard: redirect for unauthenticated, 403 for non-admin - User list: returns HTML with user table - User detail: returns HTML with user data - Profile update: modifies user profile - Groups update: modifies user groups - Activate/deactivate: toggles user active status - Delete user: removes user - Delete credentials: removes password/webauthn - Create invite: generates magic link - Re-invite: generates magic link for existing user - Self-deletion prevention Look at the existing test patterns in `tests/test_auth_routes/` for fixture and session patterns. **Step 2: Run tests** Run: `uv run python -m pytest tests/test_admin/ -v` Expected: All pass **Step 3: Commit** ``` git add tests/test_admin/ git commit -m "test: add unit tests for admin routes" ``` --- ### Task 9: Add admin link to manage nav for admin users **Files:** - Modify: `src/porchlight/templates/manage/base.html` - Modify: `src/porchlight/manage/routes.py` **Step 1: Pass `is_admin` to manage templates** In `src/porchlight/manage/routes.py`, update `credentials_page` and `profile_page` to check if user is admin and pass `is_admin` to context: ```python # In credentials_page and profile_page, after fetching the user: user = await user_repo.get_by_userid(userid) is_admin = user is not None and "admin" in user.groups # Add is_admin=is_admin to template context ``` **Step 2: Update manage/base.html** ```html {% extends "base.html" %} {% block content %} {% block manage_content %}{% endblock %} {% endblock %} ``` **Step 3: Run full test suite** Run: `uv run python -m pytest -v` Expected: All tests pass **Step 4: Commit** ``` git add src/porchlight/manage/routes.py src/porchlight/templates/manage/base.html git commit -m "feat: show admin link in manage nav for admin users" ``` --- ### Task 10: Seed admin test user + E2E tests **Files:** - Modify: `tests/e2e/setup_db.py` - Create: `tests/e2e/admin.spec.js` **Step 1: Seed test data** In `tests/e2e/setup_db.py`, add an admin user and additional regular users: ```python # 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" ``` **Step 2: Write E2E tests** Create `tests/e2e/admin.spec.js` covering: - Auth guard: unauthenticated redirect, non-admin 403 - User list: page structure, search, pagination - User detail: page structure, all sections visible - Profile update: modify and verify - Groups update: modify and verify - Activate/deactivate: toggle and verify - Create invite: generate link and verify URL - Re-invite: generate link for existing user - Delete credential: remove password - Delete user: remove user and verify redirect Use `helpers.login(page, username, password)` pattern from existing tests. **Step 3: Run E2E tests** Run from `tests/e2e/`: `bash run.sh admin.spec.js` Expected: All pass **Step 4: Run full E2E suite** Run from `tests/e2e/`: `bash run.sh` Expected: All 76+ existing tests pass + new admin tests **Step 5: Commit** ``` git add tests/e2e/setup_db.py tests/e2e/admin.spec.js git commit -m "test: add E2E tests for admin pages" ``` --- ### Task 11: Final verification **Step 1: Run full Python test suite** Run: `uv run python -m pytest -v` Expected: All tests pass **Step 2: Run full E2E test suite** Run from `tests/e2e/`: `bash run.sh` Expected: All tests pass **Step 3: Run linter** Run: `uv run ruff check src/ tests/ --fix` Expected: No errors **Step 4: Final commit if needed** Fix any lint issues and commit.