# 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 %}

Users

Create invite

{% include "admin/_user_rows.html" %}
Username Name Email Groups Status Created
{% endblock %} ``` **Step 2: Create partial templates for HTMX** Create `src/porchlight/templates/admin/_user_rows.html`: ```html {% 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 %} ``` Create `src/porchlight/templates/admin/_pagination.html`: ```html Showing {{ offset + 1 }}–{{ offset + users|length }} of {{ total }} {% if offset > 0 %} Previous {% endif %} {% if offset + per_page < total %} Next {% endif %} ``` **Step 3: Implement route handler** Replace the placeholder `users_list` in `src/porchlight/admin/routes.py`: ```python @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) user_repo = request.app.state.user_repo query = request.query_params.get("q", "").strip() offset = int(request.query_params.get("offset", "0")) per_page = 20 if query: users = await user_repo.search_users(query, offset=offset, limit=per_page) total = await user_repo.count_users(query=query) else: users = await user_repo.list_users(offset=offset, limit=per_page) total = await user_repo.count_users() templates = request.app.state.templates context = { "users": users, "query": query, "offset": offset, "per_page": per_page, "total": total, "active_page": "users", } # If HTMX search request, return just the rows if request.headers.get("HX-Request") and request.headers.get("HX-Trigger-Name") == "q": return templates.TemplateResponse(request, "admin/_user_rows.html", context) return templates.TemplateResponse(request, "admin/users.html", context) ``` **Step 4: Run full test suite** Run: `uv run python -m pytest -v` Expected: All tests pass **Step 5: Commit** ``` git add src/porchlight/admin/routes.py src/porchlight/templates/admin/ git commit -m "feat: add admin user list page with search and pagination" ``` --- ### Task 5: Create invite from admin (POST /admin/invite) **Files:** - Modify: `src/porchlight/admin/routes.py` **Step 1: Add route handler** ```python @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}:
' f'
{url}
' ) ``` Add `from fastapi import Form` to imports. **Step 2: Commit** ``` git add src/porchlight/admin/routes.py git commit -m "feat: add admin invite creation endpoint" ``` --- ### Task 6: User detail page (GET /admin/users/{userid}) **Files:** - Create: `src/porchlight/templates/admin/user_detail.html` - Modify: `src/porchlight/admin/routes.py` **Step 1: Create user detail template** Create `src/porchlight/templates/admin/user_detail.html`: ```html {% 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 %} ``` **Step 2: Add Jinja2 filter for base64 encoding credential IDs** In `src/porchlight/app.py`, after creating the templates instance, add a custom filter: ```python from base64 import urlsafe_b64encode # After: app.state.templates = Jinja2Templates(...) app.state.templates.env.filters["b64encode"] = lambda v: urlsafe_b64encode(v).decode().rstrip("=") ``` **Step 3: Add route handler** In `src/porchlight/admin/routes.py`: ```python @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", }, ) ``` **Step 4: Run full test suite** Run: `uv run python -m pytest -v` Expected: All tests pass **Step 5: Commit** ``` git add src/porchlight/admin/routes.py src/porchlight/templates/admin/user_detail.html src/porchlight/app.py git commit -m "feat: add admin user detail page with profile, groups, credentials, and actions" ``` --- ### Task 7: Admin action routes (profile, groups, activate, deactivate, credentials, invite, delete) **Files:** - Modify: `src/porchlight/admin/routes.py` **Step 1: Add all action route handlers** ```python from base64 import urlsafe_b64decode from urllib.parse import urlparse from fastapi import APIRouter, Form, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from porchlight.dependencies import get_session_user from porchlight.models import User # --- 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:
' f'
{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"}, ) ``` **Step 2: Create credentials section partial** Create `src/porchlight/templates/admin/_credentials_section.html`: ```html

Password

{% if has_password %}

Password is set.

{% else %}

No password set.

{% endif %}

Security keys

{% if webauthn_credentials %} {% else %}

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.