diff --git a/src/porchlight/admin/__init__.py b/src/porchlight/admin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/porchlight/admin/routes.py b/src/porchlight/admin/routes.py
new file mode 100644
index 0000000..44283d6
--- /dev/null
+++ b/src/porchlight/admin/routes.py
@@ -0,0 +1,348 @@
+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
+
+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)
+
+ per_page = 20
+ q = request.query_params.get("q", "")
+ offset = int(request.query_params.get("offset", "0"))
+
+ user_repo = request.app.state.user_repo
+ if q:
+ users = await user_repo.search_users(q, offset, per_page)
+ total = await user_repo.count_users(query=q)
+ else:
+ users = await user_repo.list_users(offset, per_page)
+ total = await user_repo.count_users()
+
+ context = {
+ "users": users,
+ "query": q,
+ "offset": offset,
+ "per_page": per_page,
+ "total": total,
+ "active_page": "users",
+ }
+
+ # HTMX search requests get just the table rows partial
+ if request.headers.get("HX-Request") and request.headers.get("HX-Trigger-Name") == "q":
+ templates = request.app.state.templates
+ return templates.TemplateResponse(request, "admin/_user_rows.html", context)
+
+ templates = request.app.state.templates
+ return templates.TemplateResponse(request, "admin/users.html", context)
+
+
+@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",
+ },
+ )
+
+
+@router.post("/invite", response_class=HTMLResponse)
+async def create_invite(
+ request: Request,
+ username: str = Form(),
+) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ username = username.strip()
+ if not username:
+ return HTMLResponse('
Username is required
')
+
+ magic_link_service = request.app.state.magic_link_service
+ settings = request.app.state.settings
+ link = await magic_link_service.create(username=username, created_by=admin.username, note="admin invite")
+ url = f"{settings.issuer}/register/{link.token}"
+
+ return HTMLResponse(
+ f'Invite created for {username}:
{url}
'
+ )
+
+
+# --- Profile update ---
+@router.post("/users/{userid}/profile", response_class=HTMLResponse)
+async def update_user_profile(
+ request: Request,
+ userid: str,
+ given_name: str = Form(""),
+ family_name: str = Form(""),
+ preferred_username: str = Form(""),
+ email: str = Form(""),
+ phone_number: str = Form(""),
+ picture: str = Form(""),
+ locale: str = Form(""),
+) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ # Validate
+ if email and "@" not in email:
+ return HTMLResponse('Invalid email address
')
+ if picture:
+ parsed = urlparse(picture)
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
+ return HTMLResponse('Picture URL must be a valid HTTP or HTTPS URL
')
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ updated = user.model_copy(
+ update={
+ "given_name": given_name or None,
+ "family_name": family_name or None,
+ "preferred_username": preferred_username or None,
+ "email": email or None,
+ "phone_number": phone_number or None,
+ "picture": picture or None,
+ "locale": locale or None,
+ }
+ )
+ await user_repo.update(updated)
+ return HTMLResponse('Profile updated
')
+
+
+# --- Groups update ---
+@router.post("/users/{userid}/groups", response_class=HTMLResponse)
+async def update_user_groups(
+ request: Request,
+ userid: str,
+ groups: str = Form(""),
+) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ group_list = [g.strip() for g in groups.split(",") if g.strip()]
+ updated = user.model_copy(update={"groups": group_list})
+ await user_repo.update(updated)
+ return HTMLResponse('Groups updated
')
+
+
+# --- Activate / deactivate ---
+@router.post("/users/{userid}/activate", response_class=HTMLResponse)
+async def activate_user(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ updated = user.model_copy(update={"active": True})
+ await user_repo.update(updated)
+ return HTMLResponse(
+ 'User activated
'
+ f''
+ )
+
+
+@router.post("/users/{userid}/deactivate", response_class=HTMLResponse)
+async def deactivate_user(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ updated = user.model_copy(update={"active": False})
+ await user_repo.update(updated)
+ return HTMLResponse(
+ 'User deactivated
'
+ f''
+ )
+
+
+# --- Delete credentials ---
+@router.delete("/users/{userid}/credentials/password", response_class=HTMLResponse)
+async def delete_user_password(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ cred_repo = request.app.state.credential_repo
+ await cred_repo.delete_password(userid)
+
+ # Re-render credentials section
+ webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
+ templates = request.app.state.templates
+ return templates.TemplateResponse(
+ request,
+ "admin/_credentials_section.html",
+ {
+ "target_user": await request.app.state.user_repo.get_by_userid(userid),
+ "webauthn_credentials": webauthn_credentials,
+ "has_password": False,
+ },
+ )
+
+
+@router.delete("/users/{userid}/credentials/webauthn/{credential_id_b64}", response_class=HTMLResponse)
+async def delete_user_webauthn(request: Request, userid: str, credential_id_b64: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ padded = credential_id_b64 + "=" * (-len(credential_id_b64) % 4)
+ credential_id = urlsafe_b64decode(padded)
+ cred_repo = request.app.state.credential_repo
+ await cred_repo.delete_webauthn(userid, credential_id)
+
+ # Re-render credentials section
+ webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
+ password = await cred_repo.get_password_by_user(userid)
+ templates = request.app.state.templates
+ return templates.TemplateResponse(
+ request,
+ "admin/_credentials_section.html",
+ {
+ "target_user": await request.app.state.user_repo.get_by_userid(userid),
+ "webauthn_credentials": webauthn_credentials,
+ "has_password": password is not None,
+ },
+ )
+
+
+# --- Re-invite ---
+@router.post("/users/{userid}/invite", response_class=HTMLResponse)
+async def reinvite_user(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ magic_link_service = request.app.state.magic_link_service
+ settings = request.app.state.settings
+ link = await magic_link_service.create(username=user.username, created_by=admin.username, note="admin re-invite")
+ url = f"{settings.issuer}/register/{link.token}"
+
+ return HTMLResponse(f'Invite link generated:
{url}
')
+
+
+# --- Delete user ---
+@router.delete("/users/{userid}", response_class=HTMLResponse)
+async def delete_user(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ # Prevent self-deletion
+ admin_userid, _ = get_session_user(request)
+ if userid == admin_userid:
+ return HTMLResponse('Cannot delete your own account
')
+
+ user_repo = request.app.state.user_repo
+ deleted = await user_repo.delete(userid)
+ if not deleted:
+ return HTMLResponse("User not found", status_code=404)
+
+ return HTMLResponse(
+ status_code=200,
+ content='User deleted
',
+ headers={"HX-Redirect": "/admin/users"},
+ )
diff --git a/src/porchlight/app.py b/src/porchlight/app.py
index 762df61..9580200 100644
--- a/src/porchlight/app.py
+++ b/src/porchlight/app.py
@@ -1,4 +1,5 @@
import secrets
+from base64 import urlsafe_b64encode
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
@@ -10,6 +11,7 @@ from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import Request
+from porchlight.admin.routes import router as admin_router
from porchlight.authn.password import PasswordService
from porchlight.authn.routes import router as authn_router
from porchlight.authn.webauthn import WebAuthnService
@@ -128,12 +130,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return generate_csrf_token(request)
templates.env.globals["csrf_token_processor"] = csrf_token_processor
+ templates.env.filters["b64encode"] = lambda v: urlsafe_b64encode(v).decode().rstrip("=")
app.state.templates = templates
# Static files
app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")
# Routers
+ app.include_router(admin_router)
app.include_router(authn_router)
app.include_router(manage_router)
app.include_router(oidc_router)
diff --git a/src/porchlight/manage/routes.py b/src/porchlight/manage/routes.py
index 9b9c82e..18358e8 100644
--- a/src/porchlight/manage/routes.py
+++ b/src/porchlight/manage/routes.py
@@ -26,10 +26,13 @@ async def credentials_page(request: Request) -> Response:
userid, username = session_user
cred_repo = request.app.state.credential_repo
+ user_repo = request.app.state.user_repo
webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
password_credential = await cred_repo.get_password_by_user(userid)
setup = request.query_params.get("setup")
+ user = await user_repo.get_by_userid(userid)
+ is_admin = user is not None and "admin" in user.groups
templates = request.app.state.templates
return templates.TemplateResponse(
@@ -41,6 +44,7 @@ async def credentials_page(request: Request) -> Response:
"has_password": password_credential is not None,
"setup": setup,
"active_page": "credentials",
+ "is_admin": is_admin,
},
)
@@ -177,6 +181,7 @@ async def profile_page(request: Request) -> Response:
userid, username = session_user
user_repo = request.app.state.user_repo
user = await user_repo.get_by_userid(userid)
+ is_admin = user is not None and "admin" in user.groups
templates = request.app.state.templates
return templates.TemplateResponse(
@@ -186,6 +191,7 @@ async def profile_page(request: Request) -> Response:
"username": username,
"user": user,
"active_page": "profile",
+ "is_admin": is_admin,
},
)
diff --git a/src/porchlight/static/style.css b/src/porchlight/static/style.css
index 7385b3a..cc7fa9c 100644
--- a/src/porchlight/static/style.css
+++ b/src/porchlight/static/style.css
@@ -146,6 +146,169 @@ main {
border-bottom-color: var(--accent);
}
+/* ---------- Admin ---------- */
+
+.admin-nav {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-4);
+ margin-bottom: var(--sp-6);
+ border-bottom: 1px solid var(--border);
+ padding-bottom: var(--sp-3);
+}
+
+.admin-nav a {
+ color: var(--fg-muted);
+ text-decoration: none;
+ font-weight: 500;
+ font-size: var(--font-size-sm);
+ padding-bottom: var(--sp-3);
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: color 0.15s ease, border-color 0.15s ease;
+}
+
+.admin-nav a:hover {
+ color: var(--fg);
+}
+
+.admin-nav a[aria-current="page"] {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+.admin-badge {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ color: var(--accent-fg);
+ background: var(--accent);
+ padding: var(--sp-1) var(--sp-2);
+ border-radius: var(--radius);
+ line-height: 1;
+}
+
+.admin-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+}
+
+.admin-table th,
+.admin-table td {
+ text-align: left;
+ padding: var(--sp-2) var(--sp-3);
+ border-bottom: 1px solid var(--border);
+}
+
+.admin-table th {
+ font-weight: 600;
+ color: var(--fg-muted);
+ font-size: var(--font-size-sm);
+}
+
+.admin-table tbody tr:hover {
+ background: var(--surface);
+}
+
+.status-badge {
+ display: inline-block;
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ padding: var(--sp-1) var(--sp-2);
+ border-radius: var(--radius);
+ line-height: 1;
+}
+
+.status-active {
+ background: var(--success-bg);
+ color: var(--success-fg);
+}
+
+.status-inactive {
+ background: var(--error-bg);
+ color: var(--error-fg);
+}
+
+.group-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-1);
+ font-size: var(--font-size-sm);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: var(--sp-1) var(--sp-2);
+ line-height: 1;
+}
+
+.group-tag button {
+ background: none;
+ border: none;
+ color: var(--fg-muted);
+ cursor: pointer;
+ padding: 0;
+ font-size: var(--font-size-sm);
+ line-height: 1;
+}
+
+.group-tag button:hover {
+ color: var(--error-fg);
+}
+
+.admin-search {
+ display: flex;
+ gap: var(--sp-2);
+ margin-bottom: var(--sp-4);
+}
+
+.admin-search input[type="text"] {
+ flex: 1;
+ margin-bottom: 0;
+}
+
+.pagination {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: var(--sp-4);
+ font-size: var(--font-size-sm);
+}
+
+.admin-detail section {
+ margin-bottom: var(--sp-6);
+}
+
+.invite-url {
+ font-family: ui-monospace, monospace;
+ font-size: var(--font-size-sm);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: var(--sp-3) var(--sp-4);
+ word-break: break-all;
+ margin-bottom: var(--sp-4);
+}
+
+.confirm-danger {
+ background: var(--error-bg);
+ border: 1px solid color-mix(in srgb, var(--error-fg) 20%, transparent);
+ border-radius: var(--radius);
+ padding: var(--sp-4);
+ margin-bottom: var(--sp-4);
+}
+
+@media (prefers-color-scheme: dark) {
+ .status-active {
+ background: var(--success-bg);
+ color: var(--success-fg);
+ }
+
+ .status-inactive {
+ background: var(--error-bg);
+ color: var(--error-fg);
+ }
+}
+
/* ---------- Typography ---------- */
h1 {
diff --git a/src/porchlight/store/protocols.py b/src/porchlight/store/protocols.py
index b272318..ba1af34 100644
--- a/src/porchlight/store/protocols.py
+++ b/src/porchlight/store/protocols.py
@@ -21,6 +21,10 @@ class UserRepository(Protocol):
async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]: ...
+ 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: ...
+
async def delete(self, userid: str) -> bool: ...
diff --git a/src/porchlight/store/sqlite/repositories.py b/src/porchlight/store/sqlite/repositories.py
index 22075e0..37d02bb 100644
--- a/src/porchlight/store/sqlite/repositories.py
+++ b/src/porchlight/store/sqlite/repositories.py
@@ -137,6 +137,32 @@ class SQLiteUserRepository:
users.append(self._row_to_user(row, groups))
return users
+ 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
+
async def delete(self, userid: str) -> bool:
cursor = await self._db.execute("DELETE FROM users WHERE userid = ?", (userid,))
await self._db.commit()
diff --git a/src/porchlight/templates/admin/_credentials_section.html b/src/porchlight/templates/admin/_credentials_section.html
new file mode 100644
index 0000000..b2351eb
--- /dev/null
+++ b/src/porchlight/templates/admin/_credentials_section.html
@@ -0,0 +1,27 @@
+Password
+{% if has_password %}
+Password is set.
+
+
+{% else %}
+No password set.
+{% endif %}
+
+Security keys
+{% if webauthn_credentials %}
+
+ {% for cred in webauthn_credentials %}
+ -
+ {{ cred.device_name or "Security key" }}
+ (added {{ cred.created_at.strftime('%Y-%m-%d') }})
+
+
+ {% endfor %}
+
+{% else %}
+No security keys registered.
+{% endif %}
diff --git a/src/porchlight/templates/admin/_pagination.html b/src/porchlight/templates/admin/_pagination.html
new file mode 100644
index 0000000..088724f
--- /dev/null
+++ b/src/porchlight/templates/admin/_pagination.html
@@ -0,0 +1,13 @@
+{% if total > 0 %}
+
+ Showing {{ offset + 1 }}–{{ offset + users|length }} of {{ total }}
+
+{% endif %}
+
+ {% if offset > 0 %}
+ Previous
+ {% endif %}
+ {% if offset + per_page < total %}
+ Next
+ {% endif %}
+
diff --git a/src/porchlight/templates/admin/_user_rows.html b/src/porchlight/templates/admin/_user_rows.html
new file mode 100644
index 0000000..48ab3cb
--- /dev/null
+++ b/src/porchlight/templates/admin/_user_rows.html
@@ -0,0 +1,21 @@
+{% for user in users %}
+
+ | {{ user.username }} |
+ {{ [user.given_name, user.family_name]|select|join(' ') }} |
+ {{ user.email or '' }} |
+ {% for g in user.groups %}{{ g }} {% endfor %} |
+
+
+ {% if user.active %}
+ Active
+ {% else %}
+ Inactive
+ {% endif %}
+
+ |
+ {{ user.created_at.strftime('%Y-%m-%d') }} |
+
+{% endfor %}
+{% if not users %}
+| No users found. |
+{% endif %}
diff --git a/src/porchlight/templates/admin/base.html b/src/porchlight/templates/admin/base.html
new file mode 100644
index 0000000..d838751
--- /dev/null
+++ b/src/porchlight/templates/admin/base.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+{% block admin_content %}{% endblock %}
+{% endblock %}
diff --git a/src/porchlight/templates/admin/user_detail.html b/src/porchlight/templates/admin/user_detail.html
new file mode 100644
index 0000000..147adc7
--- /dev/null
+++ b/src/porchlight/templates/admin/user_detail.html
@@ -0,0 +1,124 @@
+{% extends "admin/base.html" %}
+
+{% block title %}{{ target_user.username }} — Admin — Porchlight{% endblock %}
+
+{% block admin_content %}
+{{ target_user.username }}
+ID: {{ target_user.userid }} · Created {{ target_user.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+
+
+
+
+
+
+
+ Credentials
+
+
Password
+ {% if has_password %}
+
Password is set.
+
+
+ {% else %}
+
No password set.
+ {% endif %}
+
+
Security keys
+ {% if webauthn_credentials %}
+
+ {% for cred in webauthn_credentials %}
+ -
+ {{ cred.device_name or "Security key" }}
+ (added {{ cred.created_at.strftime('%Y-%m-%d') }})
+
+
+ {% endfor %}
+
+ {% else %}
+
No security keys registered.
+ {% endif %}
+
+
+
+
+ Actions
+
+
+ {% if target_user.active %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/src/porchlight/templates/admin/users.html b/src/porchlight/templates/admin/users.html
new file mode 100644
index 0000000..26d660b
--- /dev/null
+++ b/src/porchlight/templates/admin/users.html
@@ -0,0 +1,49 @@
+{% extends "admin/base.html" %}
+
+{% block title %}Users — Admin — Porchlight{% endblock %}
+
+{% block admin_content %}
+Users
+
+
+
+
+
+
+
+
+
+
+
+
+ | Username |
+ Name |
+ Email |
+ Groups |
+ Status |
+ Created |
+
+
+
+ {% include "admin/_user_rows.html" %}
+
+
+
+
+
+{% endblock %}
diff --git a/src/porchlight/templates/manage/base.html b/src/porchlight/templates/manage/base.html
index 15166e6..7096033 100644
--- a/src/porchlight/templates/manage/base.html
+++ b/src/porchlight/templates/manage/base.html
@@ -4,6 +4,7 @@
{% block manage_content %}{% endblock %}
{% endblock %}
diff --git a/tests/e2e/admin.spec.js b/tests/e2e/admin.spec.js
new file mode 100644
index 0000000..24971a7
--- /dev/null
+++ b/tests/e2e/admin.spec.js
@@ -0,0 +1,238 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+
+const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
+
+/** Log in as the admin user and land on /manage/credentials. */
+async function loginAsAdmin(page) {
+ await page.goto('/login');
+ await page.fill('#username', fixtures.admin_username);
+ await page.fill('#password', fixtures.admin_password);
+ await page.click('form[hx-post="/login/password"] button[type="submit"]');
+ await page.waitForURL('**/manage/credentials', { timeout: 5000 });
+}
+
+/** Log in as a regular (non-admin) user. */
+async function loginAsRegularUser(page) {
+ await page.goto('/login');
+ await page.fill('#username', fixtures.login_username);
+ await page.fill('#password', fixtures.login_password);
+ await page.click('form[hx-post="/login/password"] button[type="submit"]');
+ await page.waitForURL('**/manage/credentials', { timeout: 5000 });
+}
+
+test.describe('Admin pages', () => {
+ // ---------------------------------------------------------------
+ // 1 & 2. Auth guards
+ // ---------------------------------------------------------------
+ test.describe('Auth guard', () => {
+ test('unauthenticated user visiting /admin/users is redirected to /login', async ({ page }) => {
+ await page.goto('/admin/users');
+ await page.waitForURL('**/login', { timeout: 5000 });
+ expect(page.url()).toContain('/login');
+ });
+
+ test('non-admin logged-in user gets 403', async ({ page }) => {
+ await loginAsRegularUser(page);
+ const response = await page.goto('/admin/users');
+ expect(response.status()).toBe(403);
+ });
+ });
+
+ // ---------------------------------------------------------------
+ // 3 & 4 & 5. User list page
+ // ---------------------------------------------------------------
+ test.describe('User list page', () => {
+ test.beforeEach(async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/users');
+ });
+
+ test('has correct page structure', async ({ page }) => {
+ await expect(page.locator('h1')).toHaveText('Users');
+ await expect(page.locator('.admin-table thead th')).toHaveCount(6);
+ await expect(page.locator('input[name="q"]')).toBeVisible();
+ await expect(page.locator('form[hx-post="/admin/invite"]')).toBeVisible();
+ });
+
+ test('table headers are correct', async ({ page }) => {
+ const headers = page.locator('.admin-table thead th');
+ await expect(headers.nth(0)).toHaveText('Username');
+ await expect(headers.nth(1)).toHaveText('Name');
+ await expect(headers.nth(2)).toHaveText('Email');
+ await expect(headers.nth(3)).toHaveText('Groups');
+ await expect(headers.nth(4)).toHaveText('Status');
+ await expect(headers.nth(5)).toHaveText('Created');
+ });
+
+ test('shows seeded users', async ({ page }) => {
+ const tableBody = page.locator('#user-table-body');
+ await expect(tableBody).toContainText('testuser');
+ await expect(tableBody).toContainText('adminuser');
+ });
+
+ test('search filters results', async ({ page }) => {
+ const searchInput = page.locator('input[name="q"]');
+ await searchInput.fill('admin');
+ // Wait for htmx debounce (300ms) and response
+ await expect(page.locator('#user-table-body')).toContainText('adminuser', { timeout: 5000 });
+ // Other users should be filtered out
+ await expect(page.locator('#user-table-body')).not.toContainText('testuser', { timeout: 3000 });
+ });
+ });
+
+ // ---------------------------------------------------------------
+ // 6. User detail page structure
+ // ---------------------------------------------------------------
+ test.describe('User detail page', () => {
+ test.beforeEach(async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/users');
+ });
+
+ test('clicking a user link shows detail page', async ({ page }) => {
+ await page.click(`a:has-text("adminuser")`);
+ await page.waitForURL('**/admin/users/**', { timeout: 5000 });
+
+ await expect(page.locator('h1')).toHaveText('adminuser');
+ // Profile section
+ await expect(page.locator('h2:has-text("Profile")')).toBeVisible();
+ await expect(page.locator('#given_name')).toBeVisible();
+ // Groups section
+ await expect(page.locator('h2:has-text("Groups")')).toBeVisible();
+ await expect(page.locator('#groups')).toBeVisible();
+ // Credentials section
+ await expect(page.locator('h2:has-text("Credentials")')).toBeVisible();
+ // Actions section
+ await expect(page.locator('h2:has-text("Actions")')).toBeVisible();
+ });
+ });
+
+ // ---------------------------------------------------------------
+ // 7. Profile update
+ // ---------------------------------------------------------------
+ test.describe('Profile update', () => {
+ test('fill in profile fields and save', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto(`/admin/users/${fixtures.admin_userid}`);
+
+ await page.fill('#given_name', 'Updated');
+ await page.fill('#family_name', 'Name');
+ await page.fill('#email', 'updated@example.com');
+
+ await page.click('section:has(h2:has-text("Profile")) button[type="submit"]');
+
+ const status = page.locator('#profile-status [role="status"]');
+ await expect(status).toBeVisible({ timeout: 5000 });
+ await expect(status).toContainText('Profile updated');
+ });
+ });
+
+ // ---------------------------------------------------------------
+ // 8. Groups update
+ // ---------------------------------------------------------------
+ test.describe('Groups update', () => {
+ test('change groups and save', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto(`/admin/users/${fixtures.admin_userid}`);
+
+ await page.fill('#groups', 'admin, users, editors');
+ await page.click('section:has(h2:has-text("Groups")) button[type="submit"]');
+
+ const status = page.locator('#groups-status [role="status"]');
+ await expect(status).toBeVisible({ timeout: 5000 });
+ await expect(status).toContainText('Groups updated');
+ });
+ });
+
+ // ---------------------------------------------------------------
+ // 9. Activate/deactivate toggle
+ // ---------------------------------------------------------------
+ test.describe('Activate/deactivate toggle', () => {
+ test('deactivate then activate user', async ({ page }) => {
+ await loginAsAdmin(page);
+ // Use a non-admin user for this test
+ await page.goto(`/admin/users/${fixtures.admin_userid}`);
+
+ // Click deactivate
+ await page.click('button:has-text("Deactivate user")');
+ const deactivatedStatus = page.locator('#actions-section [role="status"]');
+ await expect(deactivatedStatus).toBeVisible({ timeout: 5000 });
+ await expect(deactivatedStatus).toContainText('User deactivated');
+
+ // Now an activate button should appear
+ await expect(page.locator('button:has-text("Activate user")')).toBeVisible();
+
+ // Click activate
+ await page.click('button:has-text("Activate user")');
+ const activatedStatus = page.locator('#actions-section [role="status"]');
+ await expect(activatedStatus).toBeVisible({ timeout: 5000 });
+ await expect(activatedStatus).toContainText('User activated');
+
+ // Deactivate button should reappear
+ await expect(page.locator('button:has-text("Deactivate user")')).toBeVisible();
+ });
+ });
+
+ // ---------------------------------------------------------------
+ // 10. Create invite from user list
+ // ---------------------------------------------------------------
+ test.describe('Create invite', () => {
+ test('fill username and submit to get invite URL', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/users');
+
+ await page.fill('form[hx-post="/admin/invite"] input[name="username"]', 'inviteduser');
+ await page.click('form[hx-post="/admin/invite"] button[type="submit"]');
+
+ const inviteStatus = page.locator('#invite-status');
+ await expect(inviteStatus.locator('.invite-url')).toBeVisible({ timeout: 5000 });
+ const inviteUrl = await inviteStatus.locator('.invite-url').textContent();
+ expect(inviteUrl).toContain('/register/');
+ });
+ });
+
+ // ---------------------------------------------------------------
+ // 11. Re-invite from user detail
+ // ---------------------------------------------------------------
+ test.describe('Re-invite from user detail', () => {
+ test('generate invite link from user detail page', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto(`/admin/users/${fixtures.admin_userid}`);
+
+ await page.click('button:has-text("Generate invite link")');
+
+ const inviteResult = page.locator('#invite-result');
+ await expect(inviteResult.locator('.invite-url')).toBeVisible({ timeout: 5000 });
+ const inviteUrl = await inviteResult.locator('.invite-url').textContent();
+ expect(inviteUrl).toContain('/register/');
+ });
+ });
+
+ // ---------------------------------------------------------------
+ // 12. Delete user
+ // ---------------------------------------------------------------
+ test.describe('Delete user', () => {
+ test('delete a user and verify redirect to user list', async ({ page }) => {
+ await loginAsAdmin(page);
+
+ // Use a disposable user seeded specifically for this test
+ await page.goto(`/admin/users/${fixtures.disposable_userid}`);
+ await expect(page.locator('h1')).toHaveText('disposableuser');
+
+ // Set up dialog handler before clicking delete
+ page.on('dialog', async (dialog) => {
+ await dialog.accept();
+ });
+
+ await page.click('button:has-text("Delete user")');
+
+ // htmx processes the HX-Redirect header and navigates to /admin/users
+ await page.waitForURL('**/admin/users', { timeout: 5000 });
+ await expect(page.locator('h1')).toHaveText('Users');
+
+ // The deleted user should no longer appear in the list
+ await expect(page.locator('#user-table-body')).not.toContainText('disposableuser');
+ });
+ });
+});
diff --git a/tests/e2e/setup_db.py b/tests/e2e/setup_db.py
index b654054..bce3821 100644
--- a/tests/e2e/setup_db.py
+++ b/tests/e2e/setup_db.py
@@ -100,6 +100,28 @@ async def seed() -> None:
result["profile_username"] = "profileuser"
result["profile_password"] = "profilepass123"
+ # 6. 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"
+
+ # 7. Disposable user for admin delete test (not used by any other tests)
+ disposable_user = User(userid="test-user-06", username="disposableuser", groups=["users"])
+ await user_repo.create(disposable_user)
+ result["disposable_userid"] = "test-user-06"
+ result["disposable_username"] = "disposableuser"
+
await db.commit()
await db.close()
print(json.dumps(result))
diff --git a/tests/test_admin/__init__.py b/tests/test_admin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_admin/test_admin_guard.py b/tests/test_admin/test_admin_guard.py
new file mode 100644
index 0000000..f58d187
--- /dev/null
+++ b/tests/test_admin/test_admin_guard.py
@@ -0,0 +1,53 @@
+from datetime import UTC, datetime
+
+import pytest
+from argon2 import PasswordHasher
+from httpx import AsyncClient
+
+from porchlight.authn.password import PasswordService
+from porchlight.models import PasswordCredential, User
+
+
+async def _login(
+ client: AsyncClient, username: str = "alice", password: str = "testpass", *, groups: list[str] | None = None
+) -> None:
+ """Helper: create user + password credential and log in via POST /login/password."""
+ app = client._transport.app # type: ignore[union-attr]
+ user_repo = app.state.user_repo
+ cred_repo = app.state.credential_repo
+
+ user = await user_repo.get_by_username(username)
+ if user is None:
+ user = User(
+ userid="test-user-01",
+ username=username,
+ groups=groups or [],
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ )
+ await user_repo.create(user)
+
+ svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
+ existing = await cred_repo.get_password_by_user(user.userid)
+ if existing is None:
+ await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password)))
+
+ await client.post(
+ "/login/password",
+ data={"username": username, "password": password},
+ headers={"HX-Request": "true"},
+ )
+
+
+@pytest.mark.asyncio
+async def test_admin_users_redirects_unauthenticated(client: AsyncClient) -> None:
+ 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: AsyncClient) -> None:
+ await _login(client, username="regularuser", password="password123", groups=["users"])
+ response = await client.get("/admin/users", follow_redirects=False)
+ assert response.status_code == 403
diff --git a/tests/test_admin/test_admin_routes.py b/tests/test_admin/test_admin_routes.py
new file mode 100644
index 0000000..53e0591
--- /dev/null
+++ b/tests/test_admin/test_admin_routes.py
@@ -0,0 +1,391 @@
+from datetime import UTC, datetime
+
+import pytest
+from argon2 import PasswordHasher
+from httpx import AsyncClient
+
+from porchlight.authn.password import PasswordService
+from porchlight.models import PasswordCredential, User, WebAuthnCredential
+
+
+async def _login(
+ client: AsyncClient,
+ username: str = "admin",
+ password: str = "adminpass",
+ *,
+ userid: str = "admin-user-01",
+ groups: list[str] | None = None,
+) -> None:
+ """Helper: create user + password credential and log in via POST /login/password."""
+ app = client._transport.app # type: ignore[union-attr]
+ user_repo = app.state.user_repo
+ cred_repo = app.state.credential_repo
+
+ user = await user_repo.get_by_username(username)
+ if user is None:
+ user = User(
+ userid=userid,
+ username=username,
+ groups=groups if groups is not None else ["admin"],
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ )
+ await user_repo.create(user)
+
+ svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
+ existing = await cred_repo.get_password_by_user(user.userid)
+ if existing is None:
+ await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password)))
+
+ await client.post(
+ "/login/password",
+ data={"username": username, "password": password},
+ headers={"HX-Request": "true"},
+ )
+
+
+async def _create_target_user(
+ client: AsyncClient,
+ *,
+ userid: str = "target-user-01",
+ username: str = "bob",
+ email: str | None = None,
+ groups: list[str] | None = None,
+ active: bool = True,
+) -> User:
+ """Helper: create a target user in the database (does NOT log in)."""
+ app = client._transport.app # type: ignore[union-attr]
+ user_repo = app.state.user_repo
+ user = User(
+ userid=userid,
+ username=username,
+ email=email,
+ groups=groups or [],
+ active=active,
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ )
+ return await user_repo.create(user)
+
+
+# --- User list ---
+
+
+@pytest.mark.asyncio
+async def test_users_list_returns_html(client: AsyncClient) -> None:
+ await _login(client)
+ response = await client.get("/admin/users")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+
+
+@pytest.mark.asyncio
+async def test_users_list_shows_users(client: AsyncClient) -> None:
+ await _login(client)
+ await _create_target_user(client, username="bob")
+ response = await client.get("/admin/users")
+ assert response.status_code == 200
+ assert "bob" in response.text
+ assert "admin" in response.text
+
+
+@pytest.mark.asyncio
+async def test_users_list_search(client: AsyncClient) -> None:
+ await _login(client)
+ await _create_target_user(client, userid="target-user-01", username="bob")
+ await _create_target_user(client, userid="target-user-02", username="carol")
+ response = await client.get("/admin/users?q=bob")
+ assert response.status_code == 200
+ assert "bob" in response.text
+ assert "carol" not in response.text
+
+
+@pytest.mark.asyncio
+async def test_users_list_htmx_search_returns_partial(client: AsyncClient) -> None:
+ await _login(client)
+ await _create_target_user(client, username="bob")
+ response = await client.get(
+ "/admin/users?q=bob",
+ headers={"HX-Request": "true", "HX-Trigger-Name": "q"},
+ )
+ assert response.status_code == 200
+ # Partial should contain user row data but not the full page layout
+ assert "bob" in response.text
+ assert "" not in response.text
+ assert " None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+ response = await client.get(f"/admin/users/{target.userid}")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "bob" in response.text
+
+
+@pytest.mark.asyncio
+async def test_user_detail_404_for_nonexistent(client: AsyncClient) -> None:
+ await _login(client)
+ response = await client.get("/admin/users/nonexistent-id")
+ assert response.status_code == 404
+ assert "not found" in response.text.lower()
+
+
+# --- Profile update ---
+
+
+@pytest.mark.asyncio
+async def test_update_profile(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+ response = await client.post(
+ f"/admin/users/{target.userid}/profile",
+ data={
+ "given_name": "Bob",
+ "family_name": "Smith",
+ "preferred_username": "bobby",
+ "email": "bob@example.com",
+ "phone_number": "+1234567890",
+ "picture": "https://example.com/bob.jpg",
+ "locale": "en-US",
+ },
+ )
+ assert response.status_code == 200
+ assert "Profile updated" in response.text
+
+ # Verify user was actually updated
+ app = client._transport.app # type: ignore[union-attr]
+ user = await app.state.user_repo.get_by_userid(target.userid)
+ assert user is not None
+ assert user.given_name == "Bob"
+ assert user.family_name == "Smith"
+ assert user.email == "bob@example.com"
+ assert user.picture == "https://example.com/bob.jpg"
+
+
+@pytest.mark.asyncio
+async def test_update_profile_invalid_email(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+ response = await client.post(
+ f"/admin/users/{target.userid}/profile",
+ data={"email": "not-an-email"},
+ )
+ assert response.status_code == 200
+ assert "Invalid email" in response.text
+
+
+@pytest.mark.asyncio
+async def test_update_profile_invalid_picture_url(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+ response = await client.post(
+ f"/admin/users/{target.userid}/profile",
+ data={"picture": "not-a-url"},
+ )
+ assert response.status_code == 200
+ assert "Picture URL must be a valid HTTP or HTTPS URL" in response.text
+
+
+@pytest.mark.asyncio
+async def test_update_profile_404_for_nonexistent(client: AsyncClient) -> None:
+ await _login(client)
+ response = await client.post(
+ "/admin/users/nonexistent-id/profile",
+ data={"given_name": "Ghost"},
+ )
+ assert response.status_code == 404
+ assert "not found" in response.text.lower()
+
+
+# --- Groups update ---
+
+
+@pytest.mark.asyncio
+async def test_update_groups(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+ response = await client.post(
+ f"/admin/users/{target.userid}/groups",
+ data={"groups": "admin, users, editors"},
+ )
+ assert response.status_code == 200
+ assert "Groups updated" in response.text
+
+ # Verify groups were actually updated
+ app = client._transport.app # type: ignore[union-attr]
+ user = await app.state.user_repo.get_by_userid(target.userid)
+ assert user is not None
+ assert set(user.groups) == {"admin", "users", "editors"}
+
+
+# --- Activate / deactivate ---
+
+
+@pytest.mark.asyncio
+async def test_activate_user(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob", active=False)
+ response = await client.post(f"/admin/users/{target.userid}/activate")
+ assert response.status_code == 200
+ assert "User activated" in response.text
+
+ app = client._transport.app # type: ignore[union-attr]
+ user = await app.state.user_repo.get_by_userid(target.userid)
+ assert user is not None
+ assert user.active is True
+
+
+@pytest.mark.asyncio
+async def test_deactivate_user(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob", active=True)
+ response = await client.post(f"/admin/users/{target.userid}/deactivate")
+ assert response.status_code == 200
+ assert "User deactivated" in response.text
+
+ app = client._transport.app # type: ignore[union-attr]
+ user = await app.state.user_repo.get_by_userid(target.userid)
+ assert user is not None
+ assert user.active is False
+
+
+# --- Delete user ---
+
+
+@pytest.mark.asyncio
+async def test_delete_user(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+ response = await client.request("DELETE", f"/admin/users/{target.userid}")
+ assert response.status_code == 200
+ assert "User deleted" in response.text
+ assert response.headers.get("hx-redirect") == "/admin/users"
+
+ # Verify user was actually deleted
+ app = client._transport.app # type: ignore[union-attr]
+ user = await app.state.user_repo.get_by_userid(target.userid)
+ assert user is None
+
+
+@pytest.mark.asyncio
+async def test_delete_user_self_prevention(client: AsyncClient) -> None:
+ await _login(client, userid="admin-user-01")
+ # Try to delete ourselves
+ response = await client.request("DELETE", "/admin/users/admin-user-01")
+ assert response.status_code == 200
+ assert "Cannot delete your own account" in response.text
+
+ # Verify admin user was NOT deleted
+ app = client._transport.app # type: ignore[union-attr]
+ user = await app.state.user_repo.get_by_userid("admin-user-01")
+ assert user is not None
+
+
+# --- Delete credentials ---
+
+
+@pytest.mark.asyncio
+async def test_delete_password_credential(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+
+ # Create a password credential for the target user
+ app = client._transport.app # type: ignore[union-attr]
+ cred_repo = app.state.credential_repo
+ svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
+ await cred_repo.create_password(PasswordCredential(user_id=target.userid, password_hash=svc.hash("bobpass")))
+
+ response = await client.request("DELETE", f"/admin/users/{target.userid}/credentials/password")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+
+ # Verify password was deleted
+ pw = await cred_repo.get_password_by_user(target.userid)
+ assert pw is None
+
+
+@pytest.mark.asyncio
+async def test_delete_webauthn_credential(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+
+ # Create a webauthn credential for the target user
+ app = client._transport.app # type: ignore[union-attr]
+ cred_repo = app.state.credential_repo
+ credential_id = b"\x01\x02\x03\x04\x05\x06\x07\x08"
+ await cred_repo.create_webauthn(
+ WebAuthnCredential(
+ user_id=target.userid,
+ credential_id=credential_id,
+ public_key=b"\x00" * 32,
+ sign_count=0,
+ device_name="test-key",
+ )
+ )
+
+ # URL uses base64url without padding
+ from base64 import urlsafe_b64encode
+
+ credential_id_b64 = urlsafe_b64encode(credential_id).decode().rstrip("=")
+
+ response = await client.request("DELETE", f"/admin/users/{target.userid}/credentials/webauthn/{credential_id_b64}")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+
+ # Verify credential was deleted
+ creds = await cred_repo.get_webauthn_by_user(target.userid)
+ assert len(creds) == 0
+
+
+# --- Create invite ---
+
+
+@pytest.mark.asyncio
+async def test_create_invite(client: AsyncClient) -> None:
+ await _login(client)
+ response = await client.post(
+ "/admin/invite",
+ data={"username": "newuser"},
+ )
+ assert response.status_code == 200
+ assert "Invite created" in response.text
+ assert "newuser" in response.text
+ assert "/register/" in response.text
+
+
+@pytest.mark.asyncio
+async def test_create_invite_empty_username(client: AsyncClient) -> None:
+ await _login(client)
+ response = await client.post(
+ "/admin/invite",
+ data={"username": " "},
+ )
+ assert response.status_code == 200
+ assert "Username is required" in response.text
+
+
+# --- Re-invite ---
+
+
+@pytest.mark.asyncio
+async def test_reinvite_user(client: AsyncClient) -> None:
+ await _login(client)
+ target = await _create_target_user(client, username="bob")
+ response = await client.post(f"/admin/users/{target.userid}/invite")
+ assert response.status_code == 200
+ assert "Invite link generated" in response.text
+ assert "/register/" in response.text
+
+
+@pytest.mark.asyncio
+async def test_reinvite_user_404_for_nonexistent(client: AsyncClient) -> None:
+ await _login(client)
+ response = await client.post("/admin/users/nonexistent-id/invite")
+ assert response.status_code == 404
+ assert "not found" in response.text.lower()
diff --git a/tests/test_store/test_sqlite_user_repo.py b/tests/test_store/test_sqlite_user_repo.py
index ff2c5e6..0239a08 100644
--- a/tests/test_store/test_sqlite_user_repo.py
+++ b/tests/test_store/test_sqlite_user_repo.py
@@ -159,6 +159,49 @@ async def test_create_duplicate_username(user_repo: SQLiteUserRepository) -> Non
await user_repo.create(_make_user(userid="different-id", username="alice"))
+async def test_search_users_by_username(user_repo: SQLiteUserRepository) -> None:
+ await user_repo.create(_make_user(userid="id-1", username="sample_user"))
+ results = await user_repo.search_users("sample", offset=0, limit=100)
+ assert len(results) == 1
+ assert results[0].userid == "id-1"
+
+
+async def test_search_users_by_email(user_repo: SQLiteUserRepository) -> None:
+ await user_repo.create(_make_user(email="alice@example.com"))
+ results = await user_repo.search_users("alice", offset=0, limit=100)
+ assert len(results) == 1
+
+
+async def test_search_users_no_match(user_repo: SQLiteUserRepository) -> None:
+ await user_repo.create(_make_user())
+ results = await user_repo.search_users("nonexistent", offset=0, limit=100)
+ assert len(results) == 0
+
+
+async def test_search_users_pagination(user_repo: SQLiteUserRepository) -> None:
+ for i in range(5):
+ await user_repo.create(_make_user(userid=f"id-{i}", username=f"user{i}", groups=["users"]))
+ 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: SQLiteUserRepository) -> None:
+ await user_repo.create(_make_user())
+ count = await user_repo.count_users()
+ assert count == 1
+
+
+async def test_count_users_with_query(user_repo: SQLiteUserRepository) -> None:
+ await user_repo.create(_make_user())
+ count = await user_repo.count_users(query="alice")
+ assert count == 1
+ count = await user_repo.count_users(query="nonexistent")
+ assert count == 0
+
+
async def test_roundtrip_preserves_all_fields(user_repo: SQLiteUserRepository) -> None:
user = _make_user(
preferred_username="ally",