Merge branch 'feature/admin-pages'

# Conflicts:
#	src/porchlight/app.py
This commit is contained in:
Johan Lundberg 2026-02-19 14:36:48 +01:00
commit 33a61ecc2a
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
20 changed files with 1542 additions and 0 deletions

View file

View file

@ -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('<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><div class="invite-url">{url}</div>'
)
# --- 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><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"},
)

View file

@ -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)

View file

@ -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,
},
)

View file

@ -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 {

View file

@ -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: ...

View file

@ -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()

View file

@ -0,0 +1,27 @@
<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 %}

View file

@ -0,0 +1,13 @@
{% if total > 0 %}
<span>
Showing {{ offset + 1 }}{{ offset + users|length }} of {{ total }}
</span>
{% endif %}
<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>

View file

@ -0,0 +1,21 @@
{% 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 %}

View file

@ -0,0 +1,9 @@
{% 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 %}

View file

@ -0,0 +1,124 @@
{% 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 %}

View file

@ -0,0 +1,49 @@
{% 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 %}

View file

@ -4,6 +4,7 @@
<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 %}