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:
@@ -64,6 +64,10 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
// Page fragments — one per sidebar entry.
|
||||
.route("/admin/pages/users", get(pages::users::index))
|
||||
.route("/admin/pages/users/create", post(pages::users::create))
|
||||
.route(
|
||||
"/admin/pages/users/:id/update-info",
|
||||
post(pages::users::update_info),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/:id/password-reset",
|
||||
post(pages::users::reset_password),
|
||||
@@ -170,6 +174,28 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
post(pages::address_books::share_remove),
|
||||
)
|
||||
.route("/admin/pages/oidc", get(pages::oidc::index))
|
||||
// Self-service profile — cookie-only, no admin gate.
|
||||
.route("/admin/pages/profile", get(pages::profile::index))
|
||||
.route(
|
||||
"/admin/pages/profile/update-info",
|
||||
post(pages::profile::update_info),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/profile/change-password",
|
||||
post(pages::profile::change_password),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/profile/totp/start",
|
||||
post(pages::profile::totp_start),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/profile/totp/confirm",
|
||||
post(pages::profile::totp_confirm),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/profile/totp/remove",
|
||||
post(pages::profile::totp_remove),
|
||||
)
|
||||
.route("/admin/pages/audit", get(pages::audit::index))
|
||||
.route("/admin/pages/recordings", get(pages::recordings::index));
|
||||
hbb_common::log::info!(
|
||||
|
||||
Reference in New Issue
Block a user