feat: add admin user detail page with profile, groups, credentials, and actions
This commit is contained in:
parent
6a9e32f74d
commit
2b8d3e9800
3 changed files with 158 additions and 0 deletions
|
|
@ -60,6 +60,38 @@ async def users_list(request: Request) -> Response:
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import secrets
|
||||
from base64 import urlsafe_b64encode
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
|
@ -110,6 +111,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
|
||||
# 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
|
||||
app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")
|
||||
|
|
|
|||
124
src/porchlight/templates/admin/user_detail.html
Normal file
124
src/porchlight/templates/admin/user_detail.html
Normal 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue