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
+66 -6
View File
@@ -58,6 +58,24 @@ pub struct UserRow {
pub avatar: String,
pub status: i64,
pub is_admin: bool,
/// `Some(sub)` when the user was provisioned via or linked to an OIDC
/// provider — they sign in via the IdP, and the dashboard treats local
/// password / TOTP changes as the operator's responsibility to think
/// twice about (the IdP is the source of truth for credentials).
pub oidc_subject: Option<String>,
}
impl UserRow {
/// True iff this account was provisioned via — or has been linked to —
/// an OIDC provider. Convenience for branching UI: hide local password
/// changes for these users so we don't accidentally let an admin
/// re-enable password sign-in (which bypasses the IdP's MFA).
pub fn is_oidc_linked(&self) -> bool {
self.oidc_subject
.as_deref()
.map(|s| !s.is_empty())
.unwrap_or(false)
}
}
pub struct NewUser<'a> {
@@ -417,7 +435,7 @@ impl Database {
pub async fn user_find_by_username(&self, username: &str) -> ResultType<Option<UserRow>> {
let row = sqlx::query(
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin, oidc_subject \
FROM users WHERE username = ?",
)
.bind(username)
@@ -428,7 +446,7 @@ impl Database {
pub async fn user_find_by_id(&self, id: i64) -> ResultType<Option<UserRow>> {
let row = sqlx::query(
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin, oidc_subject \
FROM users WHERE id = ?",
)
.bind(id)
@@ -441,6 +459,32 @@ impl Database {
/// `users_list_accessible`, which the API uses (filtering by status=1
/// and visibility through device-groups). The dashboard wants the
/// full picture.
/// Per-user "last seen" — the most recent `tokens.last_used_at` for
/// each user, since every authenticated request bumps that column
/// (api/middleware on Bearer auth, and the dashboard cookie path).
/// Users with no token (never logged in, or all expired+pruned) are
/// absent from the map. Returns datetime strings as SQLite stores
/// them; the caller decides how to format.
pub async fn users_last_seen_map(
&self,
) -> ResultType<std::collections::HashMap<i64, String>> {
let rows = sqlx::query(
"SELECT user_id, MAX(last_used_at) AS last_seen \
FROM tokens GROUP BY user_id",
)
.fetch_all(self.pool.get().await?.deref_mut())
.await?;
let mut out = std::collections::HashMap::with_capacity(rows.len());
for r in rows {
let id: i64 = r.try_get("user_id")?;
let ts: Option<String> = r.try_get("last_seen").ok();
if let Some(s) = ts {
out.insert(id, s);
}
}
Ok(out)
}
pub async fn users_list_all(
&self,
offset: i64,
@@ -451,7 +495,7 @@ impl Database {
.await?
.try_get("c")?;
let rows = sqlx::query(
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin, oidc_subject \
FROM users ORDER BY username LIMIT ? OFFSET ?",
)
.bind(limit)
@@ -882,6 +926,21 @@ impl Database {
Ok(())
}
pub async fn user_set_display_name(
&self,
user_id: i64,
display_name: &str,
) -> ResultType<()> {
sqlx::query(
"UPDATE users SET display_name = ?, updated_at = current_timestamp WHERE id = ?",
)
.bind(display_name)
.bind(user_id)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(())
}
pub async fn user_has_totp(&self, user_id: i64) -> ResultType<bool> {
let row =
sqlx::query("SELECT 1 AS ok FROM user_totp_secrets WHERE user_id = ?")
@@ -1744,7 +1803,7 @@ impl Database {
let (count_sql, list_sql): (&str, String) = if is_admin {
(
"SELECT COUNT(*) AS c FROM users WHERE status = 1",
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin, oidc_subject \
FROM users WHERE status = 1 ORDER BY username LIMIT ? OFFSET ?".to_string(),
)
} else {
@@ -2537,7 +2596,7 @@ impl Database {
email: Option<&str>,
) -> ResultType<Option<UserRow>> {
let row = sqlx::query(
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin, oidc_subject \
FROM users WHERE oidc_subject = ? LIMIT 1",
)
.bind(oidc_subject)
@@ -2618,7 +2677,7 @@ impl Database {
pub async fn user_find_by_email(&self, email: &str) -> ResultType<Option<UserRow>> {
let row = sqlx::query(
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin, oidc_subject \
FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
)
.bind(email)
@@ -2735,6 +2794,7 @@ fn row_to_user(row: sqlx::sqlite::SqliteRow) -> UserRow {
avatar: row.try_get("avatar").unwrap_or_default(),
status: row.try_get("status").unwrap_or(1),
is_admin: is_admin != 0,
oidc_subject: row.try_get::<Option<String>, _>("oidc_subject").ok().flatten(),
}
}