1365 lines
41 KiB
Markdown
1365 lines
41 KiB
Markdown
# 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.
|