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:
+66
-6
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user