feat: add admin user detail page with profile, groups, credentials, and actions

This commit is contained in:
Johan Lundberg 2026-02-19 13:44:14 +01:00
parent 6a9e32f74d
commit 2b8d3e9800
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
3 changed files with 158 additions and 0 deletions

View file

@ -60,6 +60,38 @@ async def users_list(request: Request) -> Response:
return templates.TemplateResponse(request, "admin/users.html", context) 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) @router.post("/invite", response_class=HTMLResponse)
async def create_invite( async def create_invite(
request: Request, request: Request,

View file

@ -1,4 +1,5 @@
import secrets import secrets
from base64 import urlsafe_b64encode
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@ -110,6 +111,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# Templates # Templates
app.state.templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates")) app.state.templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates"))
app.state.templates.env.filters["b64encode"] = lambda v: urlsafe_b64encode(v).decode().rstrip("=")
# Static files # Static files
app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static") app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")

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 %}