porchlight/docs/plans/2026-02-18-admin-pages-plan.md
2026-04-10 11:28:51 +02:00

41 KiB
Raw Blame History

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:

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:

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:

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:

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:

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:

from porchlight.admin.routes import router as admin_router

And in create_app(), add after the other routers:

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:

{% extends "base.html" %}

{% block content %}
<nav class="admin-nav" aria-label="Administration">
    <span class="admin-badge">Admin</span>
    <a href="/admin/users" {% if active_page == "users" %}aria-current="page"{% endif %}>Users</a>
</nav>
{% block admin_content %}{% endblock %}
{% endblock %}

Step 2: Add admin CSS

In src/porchlight/static/style.css, add (after the .manage-nav section):

/* 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:

/* 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:

{% extends "admin/base.html" %}

{% block title %}Users — Admin — Porchlight{% endblock %}

{% block admin_content %}
<h1>Users</h1>

<section>
    <h2>Create invite</h2>
    <form hx-post="/admin/invite" hx-target="#invite-status" hx-swap="innerHTML">
        <div class="admin-search">
            <input type="text" name="username" placeholder="Username for new invite" required>
            <button type="submit">Create invite</button>
        </div>
    </form>
    <div id="invite-status"></div>
</section>

<section>
    <div class="admin-search">
        <input type="search" name="q" placeholder="Search by username or email..."
               hx-get="/admin/users" hx-target="#user-table-body" hx-swap="innerHTML"
               hx-trigger="input changed delay:300ms, search"
               hx-include="this" hx-push-url="false"
               value="{{ query or '' }}">
    </div>

    <div id="user-table-container">
        <table class="admin-table">
            <thead>
                <tr>
                    <th>Username</th>
                    <th>Name</th>
                    <th>Email</th>
                    <th>Groups</th>
                    <th>Status</th>
                    <th>Created</th>
                </tr>
            </thead>
            <tbody id="user-table-body">
                {% include "admin/_user_rows.html" %}
            </tbody>
        </table>
        <div class="pagination" id="pagination">
            {% include "admin/_pagination.html" %}
        </div>
    </div>
</section>
{% endblock %}

Step 2: Create partial templates for HTMX

Create src/porchlight/templates/admin/_user_rows.html:

{% for user in users %}
<tr>
    <td><a href="/admin/users/{{ user.userid }}">{{ user.username }}</a></td>
    <td>{{ [user.given_name, user.family_name]|select|join(' ') }}</td>
    <td>{{ user.email or '' }}</td>
    <td>{% for g in user.groups %}<span class="group-tag">{{ g }}</span> {% endfor %}</td>
    <td>
        <span id="status-{{ user.userid }}">
        {% if user.active %}
            <span class="status-badge status-active">Active</span>
        {% else %}
            <span class="status-badge status-inactive">Inactive</span>
        {% endif %}
        </span>
    </td>
    <td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
</tr>
{% endfor %}
{% if not users %}
<tr><td colspan="6">No users found.</td></tr>
{% endif %}

Create src/porchlight/templates/admin/_pagination.html:

<span>
    Showing {{ offset + 1 }}{{ offset + users|length }} of {{ total }}
</span>
<span>
    {% if offset > 0 %}
    <a href="/admin/users?offset={{ offset - per_page }}&q={{ query or '' }}">Previous</a>
    {% endif %}
    {% if offset + per_page < total %}
    <a href="/admin/users?offset={{ offset + per_page }}&q={{ query or '' }}">Next</a>
    {% endif %}
</span>

Step 3: Implement route handler

Replace the placeholder users_list in src/porchlight/admin/routes.py:

@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

@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('<div role="alert">Username is required</div>')

    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'<div role="status">Invite created for <strong>{username}</strong>:</div>'
        f'<div class="invite-url">{url}</div>'
    )

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:

{% extends "admin/base.html" %}

{% block title %}{{ target_user.username }} — Admin — Porchlight{% endblock %}

{% block admin_content %}
<h1>{{ target_user.username }}</h1>
<p>ID: <code>{{ target_user.userid }}</code> · Created {{ target_user.created_at.strftime('%Y-%m-%d %H:%M') }}</p>

<div class="admin-detail">

<section>
    <h2>Profile</h2>
    <form hx-post="/admin/users/{{ target_user.userid }}/profile" hx-target="#profile-status" hx-swap="innerHTML">
        <div>
            <label for="given_name">Given name</label>
            <input type="text" id="given_name" name="given_name" value="{{ target_user.given_name or '' }}" maxlength="255">
        </div>
        <div>
            <label for="family_name">Family name</label>
            <input type="text" id="family_name" name="family_name" value="{{ target_user.family_name or '' }}" maxlength="255">
        </div>
        <div>
            <label for="preferred_username">Display name</label>
            <input type="text" id="preferred_username" name="preferred_username" value="{{ target_user.preferred_username or '' }}" maxlength="255">
        </div>
        <div>
            <label for="email">Email</label>
            <input type="email" id="email" name="email" value="{{ target_user.email or '' }}" maxlength="255">
        </div>
        <div>
            <label for="phone_number">Phone number</label>
            <input type="tel" id="phone_number" name="phone_number" value="{{ target_user.phone_number or '' }}" maxlength="50">
        </div>
        <div>
            <label for="picture">Picture URL</label>
            <input type="url" id="picture" name="picture" value="{{ target_user.picture or '' }}" maxlength="2048">
        </div>
        <div>
            <label for="locale">Locale</label>
            <input type="text" id="locale" name="locale" value="{{ target_user.locale or '' }}" maxlength="20" placeholder="e.g. en, sv-SE">
        </div>
        <div id="profile-status"></div>
        <button type="submit">Save profile</button>
    </form>
</section>

<section>
    <h2>Groups</h2>
    <div id="groups-section">
        <form hx-post="/admin/users/{{ target_user.userid }}/groups" hx-target="#groups-status" hx-swap="innerHTML">
            <div id="group-list">
                {% for group in target_user.groups %}
                <span class="group-tag">{{ group }}</span>
                {% endfor %}
            </div>
            <div>
                <label for="groups">Groups (comma-separated)</label>
                <input type="text" id="groups" name="groups" value="{{ target_user.groups|join(', ') }}">
            </div>
            <div id="groups-status"></div>
            <button type="submit">Update groups</button>
        </form>
    </div>
</section>

<section>
    <h2>Credentials</h2>
    <div id="credentials-section">
        <h3>Password</h3>
        {% if has_password %}
        <p>Password is set.
            <button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}/credentials/password"
                    hx-target="#credentials-section" hx-swap="innerHTML"
                    hx-confirm="Remove this user's password?">Remove password</button>
        </p>
        {% else %}
        <p>No password set.</p>
        {% endif %}

        <h3>Security keys</h3>
        {% if webauthn_credentials %}
        <ul>
            {% for cred in webauthn_credentials %}
            <li>
                {{ cred.device_name or "Security key" }}
                <small>(added {{ cred.created_at.strftime('%Y-%m-%d') }})</small>
                <button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}/credentials/webauthn/{{ cred.credential_id|b64encode }}"
                        hx-target="#credentials-section" hx-swap="innerHTML"
                        hx-confirm="Remove this security key?">Remove</button>
            </li>
            {% endfor %}
        </ul>
        {% else %}
        <p>No security keys registered.</p>
        {% endif %}
    </div>
</section>

<section>
    <h2>Actions</h2>
    <div id="actions-section">
        <div>
            {% if target_user.active %}
            <button class="btn-secondary" hx-post="/admin/users/{{ target_user.userid }}/deactivate"
                    hx-target="#actions-section" hx-swap="innerHTML">Deactivate user</button>
            {% else %}
            <button class="btn-secondary" hx-post="/admin/users/{{ target_user.userid }}/activate"
                    hx-target="#actions-section" hx-swap="innerHTML">Activate user</button>
            {% endif %}
        </div>
        <div>
            <button class="btn-secondary" hx-post="/admin/users/{{ target_user.userid }}/invite"
                    hx-target="#invite-result" hx-swap="innerHTML">Generate invite link</button>
            <div id="invite-result"></div>
        </div>
        <div>
            <button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}"
                    hx-target="body" hx-confirm="Permanently delete user {{ target_user.username }}? This cannot be undone.">Delete user</button>
        </div>
    </div>
</section>

</div>
{% 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:

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:

@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

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('<div role="alert">Invalid email address</div>')
    if picture:
        parsed = urlparse(picture)
        if parsed.scheme not in ("http", "https") or not parsed.netloc:
            return HTMLResponse('<div role="alert">Picture URL must be a valid HTTP or HTTPS URL</div>')

    user_repo = request.app.state.user_repo
    user = await user_repo.get_by_userid(userid)
    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('<div role="status">Profile updated</div>')


# --- 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('<div role="status">Groups updated</div>')


# --- 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(
        '<div role="status">User activated</div>'
        f'<button class="btn-secondary" hx-post="/admin/users/{userid}/deactivate"'
        ' hx-target="#actions-section" hx-swap="innerHTML">Deactivate user</button>'
    )


@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(
        '<div role="status">User deactivated</div>'
        f'<button class="btn-secondary" hx-post="/admin/users/{userid}/activate"'
        ' hx-target="#actions-section" hx-swap="innerHTML">Activate user</button>'
    )


# --- 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'<div role="status">Invite link generated:</div>'
        f'<div class="invite-url">{url}</div>'
    )


# --- 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('<div role="alert">Cannot delete your own account</div>')

    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='<div role="status">User deleted</div>',
        headers={"HX-Redirect": "/admin/users"},
    )

Step 2: Create credentials section partial

Create src/porchlight/templates/admin/_credentials_section.html:

<h3>Password</h3>
{% if has_password %}
<p>Password is set.
    <button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}/credentials/password"
            hx-target="#credentials-section" hx-swap="innerHTML"
            hx-confirm="Remove this user's password?">Remove password</button>
</p>
{% else %}
<p>No password set.</p>
{% endif %}

<h3>Security keys</h3>
{% if webauthn_credentials %}
<ul>
    {% for cred in webauthn_credentials %}
    <li>
        {{ cred.device_name or "Security key" }}
        <small>(added {{ cred.created_at.strftime('%Y-%m-%d') }})</small>
        <button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}/credentials/webauthn/{{ cred.credential_id|b64encode }}"
                hx-target="#credentials-section" hx-swap="innerHTML"
                hx-confirm="Remove this security key?">Remove</button>
    </li>
    {% endfor %}
</ul>
{% else %}
<p>No security keys registered.</p>
{% 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"

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:

# 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

{% extends "base.html" %}

{% block content %}
<nav class="manage-nav" aria-label="Account management">
    <a href="/manage/profile" {% if active_page == "profile" %}aria-current="page"{% endif %}>Profile</a>
    <a href="/manage/credentials" {% if active_page == "credentials" %}aria-current="page"{% endif %}>Credentials</a>
    {% if is_admin %}<a href="/admin/users">Admin</a>{% endif %}
</nav>
{% 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:

# 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.