feat(admin): user-administration QoL — last-seen, profile page, OIDC awareness

Bundles the dashboard improvements that landed since 782e4c5 into one
commit. None of these change wire protocols or DB schema; they're all
UI + handlers on top of existing tables.

Users page (/admin/#users)
- "Last seen" column derived from MAX(tokens.last_used_at) per user
  (single GROUP BY query in users_last_seen_map). Shows relative
  short-form ("5m ago", "3h ago", "2d ago") with the absolute UTC
  timestamp in the cell title= for hover.
- Per-row dropdown gains an inline "Edit profile" form (display name,
  email, Save) so admins can edit other users' info without going
  through the self-service profile.
- "Enroll TOTP" button removed from the dropdown — TOTP enrollment is
  now self-service only, so admin-side enroll (which generated a secret
  out-of-band with no QR/confirm) is dead UX. "Disable TOTP" stays,
  shown only when the user has it enrolled, with hx-confirm.
- Per-row action popover (the ··· menu) now closes on outside click,
  via a global handler in index.html that targets details.relative.
  Deploy page's collapsible help section is unaffected (no `relative`).

Self-service profile page (/admin/#profile)
- New page accessible to any signed-in user (no admin gate). Sections:
  * Profile info — display name, email
  * Change password — requires current password + new + confirm
  * Two-factor authentication — enroll/disable
- TOTP enrollment is two-step with QR confirmation. POST .../totp/start
  generates a fresh secret, renders a server-side SVG QR code (new
  `qrcode` crate dependency, no_std SVG renderer) plus the manual-entry
  base32 secret. The secret rides in a hidden form field; nothing is
  written to user_totp_secrets until the user submits a valid 6-digit
  code at .../totp/confirm. Wrong code re-renders the same QR with a
  "code didn't match" notice so the user can retry without re-scanning.
- TOTP removal requires the current password.
- Sidebar now has a "My profile" link at the bottom.

OIDC linkage awareness
- UserRow exposes oidc_subject (was already in schema, just not surfaced
  in the struct). UserRow::is_oidc_linked() returns true for non-empty.
- Admin Users page: for OIDC-linked rows the password-set form is
  replaced by a small italic note ("Linked to OIDC — password sign-in
  is disabled."). Server-side, reset_password also rejects with the
  same message — UI hide is cosmetic; the handler check is the actual
  guarantee.
- TOTP column doubles as an auth-path indicator: OIDC-linked users get
  a cyan "OIDC" badge instead of (or in preference to) the violet
  "enrolled" badge.
- Self-service profile page: change-password and TOTP sections become
  short notes ("Your account signs in via the identity provider …" /
  "MFA is managed by your identity provider") for OIDC users.
  change_password handler also short-circuits with the same message.

Login page error fragment
- The auth handler returns 401 with an HTML body for bad credentials /
  disabled / not-admin / bad-TOTP, but HTMX skips the swap on 4xx by
  default — so login errors silently never appeared. Form now has
  hx-on::before-swap that forces shouldSwap=true and clears isError on
  4xx, but only for this form (page-level htmx:responseError handler
  that bounces 401s to /admin/login.html still applies elsewhere — it
  wouldn't loop here since this form sets isError=false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:55:29 +02:00
parent 782e4c545e
commit 4ccfe7a0e6
9 changed files with 835 additions and 20 deletions
+13 -1
View File
@@ -46,7 +46,9 @@
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
</nav>
<div class="px-2 py-3 border-t border-slate-800">
<div class="px-2 py-3 border-t border-slate-800 space-y-1">
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
hx-get="/admin/pages/profile" hx-target="#main" hx-push-url="#profile">My profile</a>
<button
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
hx-post="/admin/logout"
@@ -84,6 +86,16 @@
window.location.href = '/admin/login.html';
}
});
// Close any open per-row action popover when a click happens outside it.
// The action dropdowns are <details class="... relative"> with an
// absolutely-positioned panel; the deploy page uses <details> too but
// without `relative`, so the selector is specific to the popover style.
document.addEventListener('click', (e) => {
document.querySelectorAll('details.relative[open]').forEach(d => {
if (!d.contains(e.target)) d.removeAttribute('open');
});
});
</script>
</body>
</html>
+9
View File
@@ -22,6 +22,15 @@
hx-post="/admin/login"
hx-target="#err"
hx-swap="innerHTML"
hx-on::before-swap="
/* The auth handler returns 401 with an HTML error fragment for
bad credentials / disabled / not-admin / bad-TOTP. HTMX skips
the swap on 4xx by default, so force it back on. */
if (event.detail.xhr.status >= 400 && event.detail.xhr.status < 500) {
event.detail.shouldSwap = true;
event.detail.isError = false;
}
"
hx-on::after-request="
const xhr = event.detail.xhr;
if (event.detail.successful && (xhr.responseText || '').trim() === '') {