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

1365 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 %}
<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):
```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 %}
<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`:
```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`:
```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`:
```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('<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`:
```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:
```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('<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`:
```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"
```
---
### 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 %}
<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:
```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.