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
Generated
+7
View File
@@ -1124,6 +1124,7 @@ dependencies = [
"minreq", "minreq",
"once_cell", "once_cell",
"ping", "ping",
"qrcode",
"regex", "regex",
"reqwest", "reqwest",
"rust-ini", "rust-ini",
@@ -2137,6 +2138,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe" checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]] [[package]]
name = "quickcheck" name = "quickcheck"
version = "1.0.3" version = "1.0.3"
+1
View File
@@ -20,6 +20,7 @@ path = "src/utils.rs"
hbb_common = { path = "libs/hbb_common" } hbb_common = { path = "libs/hbb_common" }
tokio = { version = "1", features = ["fs", "io-util"] } tokio = { version = "1", features = ["fs", "io-util"] }
totp-rs = { version = "5.4", default-features = false } totp-rs = { version = "5.4", default-features = false }
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder"] } lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder"] }
toml = "0.7" toml = "0.7"
serde_derive = "1.0" serde_derive = "1.0"
+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" <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> hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
</nav> </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 <button
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800" class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
hx-post="/admin/logout" hx-post="/admin/logout"
@@ -84,6 +86,16 @@
window.location.href = '/admin/login.html'; 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> </script>
</body> </body>
</html> </html>
+9
View File
@@ -22,6 +22,15 @@
hx-post="/admin/login" hx-post="/admin/login"
hx-target="#err" hx-target="#err"
hx-swap="innerHTML" 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=" hx-on::after-request="
const xhr = event.detail.xhr; const xhr = event.detail.xhr;
if (event.detail.successful && (xhr.responseText || '').trim() === '') { if (event.detail.successful && (xhr.responseText || '').trim() === '') {
+26
View File
@@ -64,6 +64,10 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
// Page fragments — one per sidebar entry. // Page fragments — one per sidebar entry.
.route("/admin/pages/users", get(pages::users::index)) .route("/admin/pages/users", get(pages::users::index))
.route("/admin/pages/users/create", post(pages::users::create)) .route("/admin/pages/users/create", post(pages::users::create))
.route(
"/admin/pages/users/:id/update-info",
post(pages::users::update_info),
)
.route( .route(
"/admin/pages/users/:id/password-reset", "/admin/pages/users/:id/password-reset",
post(pages::users::reset_password), 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), post(pages::address_books::share_remove),
) )
.route("/admin/pages/oidc", get(pages::oidc::index)) .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/audit", get(pages::audit::index))
.route("/admin/pages/recordings", get(pages::recordings::index)); .route("/admin/pages/recordings", get(pages::recordings::index));
hbb_common::log::info!( hbb_common::log::info!(
+1
View File
@@ -8,6 +8,7 @@ pub mod deploy;
pub mod devices; pub mod devices;
pub mod groups; pub mod groups;
pub mod oidc; pub mod oidc;
pub mod profile;
pub mod recordings; pub mod recordings;
pub mod shared; pub mod shared;
pub mod strategies; pub mod strategies;
+555
View File
@@ -0,0 +1,555 @@
//! `/admin/pages/profile` — self-service profile page for the
//! currently-signed-in user. Anyone with a valid dashboard cookie can
//! reach this; no admin gate (the user-management page elsewhere is
//! the admin-side equivalent for editing OTHER users).
//!
//! Flow for each section:
//! - Profile info → POST update-info (display_name, email)
//! - Password → POST change-password (current_pw, new_pw, confirm)
//! - TOTP enroll → POST totp/start (generate secret + QR)
//! → POST totp/confirm (verify 6-digit code)
//! - TOTP remove → POST totp/remove (current_pw)
//!
//! TOTP enrollment is two-step: a freshly-generated secret is shown to
//! the user as a QR code AND echoed in a hidden form field. Until the
//! user submits a valid 6-digit code derived from that secret, nothing
//! is written to `user_totp_secrets`. This means a half-finished enroll
//! (user closes the tab) leaves no garbage state.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::api::users::{hash_password, verify_password};
use axum::extract::{Extension, Form};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
use totp_rs::Secret;
// ---------- index ----------
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
) -> Result<Html<String>, ApiError> {
Ok(Html(render_full_page(&state, &user, None).await?))
}
// ---------- update profile info ----------
#[derive(Debug, Deserialize)]
pub struct InfoForm {
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub email: String,
}
pub async fn update_info(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Form(form): Form<InfoForm>,
) -> Result<Html<String>, ApiError> {
state
.db
.user_set_display_name(user.user_id, form.display_name.trim())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
state
.db
.raw_update_user_email(user.user_id, form.email.trim())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_full_page(&state, &user, Some(("ok", "Profile updated."))).await?,
))
}
// ---------- change password ----------
#[derive(Debug, Deserialize)]
pub struct PasswordForm {
pub current_password: String,
pub new_password: String,
pub confirm_password: String,
}
pub async fn change_password(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Form(form): Form<PasswordForm>,
) -> Result<Html<String>, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
if row.is_oidc_linked() {
return Ok(Html(
render_full_page(
&state,
&user,
Some((
"error",
"Your account signs in via the identity provider — change the password there.",
)),
)
.await?,
));
}
if form.new_password.len() < 4 {
return Ok(Html(
render_full_page(
&state,
&user,
Some(("error", "New password must be at least 4 characters.")),
)
.await?,
));
}
if form.new_password != form.confirm_password {
return Ok(Html(
render_full_page(
&state,
&user,
Some(("error", "New password and confirmation don't match.")),
)
.await?,
));
}
let pw_ok = verify_password(row.password_hash.clone(), form.current_password)
.await
.unwrap_or(false);
if !pw_ok {
return Ok(Html(
render_full_page(
&state,
&user,
Some(("error", "Current password is incorrect.")),
)
.await?,
));
}
let hash = hash_password(form.new_password)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
state
.db
.user_set_password(user.user_id, &hash)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_full_page(&state, &user, Some(("ok", "Password updated."))).await?,
))
}
// ---------- TOTP: start ----------
/// `POST /admin/pages/profile/totp/start` — generate a fresh secret
/// and render the QR + confirm form. Nothing is written to the DB
/// yet; the secret rides in a hidden form field until confirm.
pub async fn totp_start(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
) -> Result<Html<String>, ApiError> {
// Reject if the user already has TOTP — they should remove it first.
let already = state
.db
.user_has_totp(user.user_id)
.await
.unwrap_or(false);
if already {
return Ok(Html(
render_full_page(
&state,
&user,
Some(("error", "You already have TOTP enrolled. Disable it first if you want to re-enroll.")),
)
.await?,
));
}
let raw = sodiumoxide::randombytes::randombytes(20);
let secret_b32 = Secret::Raw(raw).to_encoded().to_string();
let issuer = "RustDesk";
let label = format!("{}:{}", issuer, user.name);
let otpauth = format!(
"otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
label = url_encode(&label),
secret = url_encode(&secret_b32),
issuer = url_encode(issuer),
);
let qr_svg = render_qr_svg(&otpauth);
Ok(Html(render_totp_enroll_panel(
&state,
&user,
&secret_b32,
&qr_svg,
None,
)
.await?))
}
// ---------- TOTP: confirm ----------
#[derive(Debug, Deserialize)]
pub struct TotpConfirmForm {
pub secret_b32: String,
pub code: String,
}
pub async fn totp_confirm(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Form(form): Form<TotpConfirmForm>,
) -> Result<Html<String>, ApiError> {
let code = form.code.trim();
let secret = form.secret_b32.trim();
if secret.is_empty() {
return Ok(Html(
render_full_page(
&state,
&user,
Some(("error", "Missing secret in confirm form.")),
)
.await?,
));
}
let valid = crate::api::auth::verify_totp(secret, code).unwrap_or(false);
if !valid {
// Re-render the enroll panel with the same secret so the user
// can try again — losing the QR forces them to start over and
// re-scan, which is annoying when the only error was a typo'd
// code.
let issuer = "RustDesk";
let label = format!("{}:{}", issuer, user.name);
let otpauth = format!(
"otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
label = url_encode(&label),
secret = url_encode(secret),
issuer = url_encode(issuer),
);
let qr_svg = render_qr_svg(&otpauth);
return Ok(Html(
render_totp_enroll_panel(
&state,
&user,
secret,
&qr_svg,
Some(("error", "Code didn't match. Try again — make sure the time on the authenticator device is in sync.")),
)
.await?,
));
}
state
.db
.totp_enroll(user.user_id, secret)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_full_page(
&state,
&user,
Some(("ok", "TOTP enrolled. Future sign-ins will require a 6-digit code.")),
)
.await?,
))
}
// ---------- TOTP: remove ----------
#[derive(Debug, Deserialize)]
pub struct TotpRemoveForm {
pub current_password: String,
}
pub async fn totp_remove(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Form(form): Form<TotpRemoveForm>,
) -> Result<Html<String>, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
let pw_ok = verify_password(row.password_hash.clone(), form.current_password)
.await
.unwrap_or(false);
if !pw_ok {
return Ok(Html(
render_full_page(
&state,
&user,
Some(("error", "Current password is incorrect — TOTP not removed.")),
)
.await?,
));
}
state
.db
.totp_unenroll(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_full_page(&state, &user, Some(("ok", "TOTP removed."))).await?,
))
}
// ---------- rendering ----------
async fn render_full_page(
state: &Arc<AppState>,
user: &AuthedUser,
notice: Option<(&str, &str)>,
) -> Result<String, ApiError> {
render_full_page_with_totp_override(state, user, notice, None).await
}
/// `totp_panel_override = Some(html)` swaps in a custom TOTP block —
/// used during enrollment confirm so the QR code panel sits where the
/// status badge would normally be, and the password / profile sections
/// stay visible above it.
async fn render_full_page_with_totp_override(
state: &Arc<AppState>,
user: &AuthedUser,
notice: Option<(&str, &str)>,
totp_panel_override: Option<String>,
) -> Result<String, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
let has_totp = state
.db
.user_has_totp(user.user_id)
.await
.unwrap_or(false);
let notice_block = notice
.map(|(k, m)| notice_html(k, m))
.unwrap_or_default();
// OIDC-linked accounts sign in via the IdP — local password and
// local TOTP both moot. Replace those sections with a short note.
let oidc_linked = row.is_oidc_linked();
let password_section = if oidc_linked {
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">Password</h3>
<p class="text-sm text-slate-400">
Your account signs in via your organisation's identity provider — there's no local password to change here.
</p>
</section>"##
.to_string()
} else {
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">Change password</h3>
<form
class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm"
hx-post="/admin/pages/profile/change-password"
hx-target="#main"
hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<input name="current_password" type="password" required placeholder="current password" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<input name="new_password" type="password" required minlength="4" placeholder="new password" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<input name="confirm_password" type="password" required minlength="4" placeholder="confirm new" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<button type="submit" class="sm:col-span-3 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">Update password</button>
</form>
</section>"##.to_string()
};
let totp_section = if let Some(panel) = totp_panel_override {
panel
} else if oidc_linked {
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">Two-factor authentication</h3>
<p class="text-sm text-slate-400">
MFA is managed by your identity provider — local TOTP isn't used for OIDC sign-ins.
</p>
</section>"##
.to_string()
} else if has_totp {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">Two-factor authentication</h3>
<div class="flex items-center gap-3 mb-3">
<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">enrolled</span>
<span class="text-xs text-slate-400">Sign-ins require a 6-digit code from your authenticator.</span>
</div>
<form
class="flex gap-2 items-center text-sm"
hx-post="/admin/pages/profile/totp/remove"
hx-target="#main"
hx-swap="innerHTML"
hx-confirm="Remove TOTP from your account?"
>
<input name="current_password" type="password" required placeholder="current password" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 flex-1 max-w-xs"/>
<button class="bg-rose-700 hover:bg-rose-600 rounded px-3 py-1.5 text-white text-xs">Disable TOTP</button>
</form>
</section>"##
)
} else {
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">Two-factor authentication</h3>
<p class="text-sm text-slate-400 mb-3">
Add a TOTP authenticator (1Password, Authy, Google Authenticator, etc.) so sign-ins also require a 6-digit code.
</p>
<button
class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm"
hx-post="/admin/pages/profile/totp/start"
hx-target="#main"
hx-swap="innerHTML"
>Enroll TOTP</button>
</section>"##.to_string()
};
Ok(format!(
r##"<div class="space-y-6 max-w-3xl">
<header>
<h2 class="text-lg font-semibold">Profile</h2>
<p class="text-xs text-slate-500 mt-0.5">Signed in as <span class="text-slate-300">{username}</span></p>
</header>
{notice}
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">Profile info</h3>
<form
class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm"
hx-post="/admin/pages/profile/update-info"
hx-target="#main"
hx-swap="innerHTML"
>
<label class="block">
<span class="text-xs text-slate-400">Display name</span>
<input name="display_name" value="{display_name}" class="mt-1 w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
</label>
<label class="block">
<span class="text-xs text-slate-400">Email</span>
<input name="email" type="email" value="{email}" class="mt-1 w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
</label>
<button type="submit" class="sm:col-span-2 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">Save</button>
</form>
</section>
{password_section}
{totp_section}
</div>"##,
username = html_escape(&user.name),
display_name = html_escape(&row.display_name),
email = html_escape(&row.email),
notice = notice_block,
password_section = password_section,
totp_section = totp_section,
))
}
async fn render_totp_enroll_panel(
state: &Arc<AppState>,
user: &AuthedUser,
secret_b32: &str,
qr_svg: &str,
notice: Option<(&str, &str)>,
) -> Result<String, ApiError> {
let panel = format!(
r##"<section class="rounded-md border border-sky-700/60 bg-sky-900/20 p-4">
<h3 class="text-sm font-semibold text-sky-200 mb-2">Confirm TOTP enrollment</h3>
<p class="text-xs text-sky-200/80 mb-3">
Scan the QR code with your authenticator app, then enter the 6-digit code it shows to confirm.
Nothing is enrolled until you submit a valid code.
</p>
<div class="flex flex-col sm:flex-row gap-4 items-start">
<div class="bg-white p-2 rounded inline-block">{qr}</div>
<div class="flex-1 space-y-2 text-sm">
<div>
<span class="text-xs text-slate-400">Secret (manual entry):</span>
<code class="block mt-1 break-all text-emerald-200 bg-slate-950 px-2 py-1.5 rounded text-xs">{secret}</code>
</div>
<form
class="flex gap-2 items-stretch"
hx-post="/admin/pages/profile/totp/confirm"
hx-target="#main"
hx-swap="innerHTML"
>
<input type="hidden" name="secret_b32" value="{secret}"/>
<input
name="code"
type="text"
inputmode="numeric"
pattern="[0-9]{{6}}"
required
maxlength="6"
autocomplete="one-time-code"
placeholder="123456"
class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 w-32 font-mono tracking-widest"
/>
<button type="submit" class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">Confirm</button>
</form>
</div>
</div>
</section>"##,
qr = qr_svg,
secret = html_escape(secret_b32),
);
render_full_page_with_totp_override(state, user, notice, Some(panel)).await
}
fn render_qr_svg(payload: &str) -> String {
// qrcode 0.14: build the QR, then render with the SVG renderer.
// .min_dimensions caps how big the SVG-pixel grid is; the actual
// CSS size is handled by inline width/height, but the underlying
// module size needs to be reasonable so it stays crisp on retina.
use qrcode::render::svg;
use qrcode::QrCode;
match QrCode::new(payload.as_bytes()) {
Ok(code) => code
.render::<svg::Color<'_>>()
.min_dimensions(180, 180)
.dark_color(svg::Color("#000"))
.light_color(svg::Color("#fff"))
.build(),
Err(_) => "<div class=\"text-rose-300 text-xs\">QR encode failed</div>".to_string(),
}
}
fn notice_html(kind: &str, msg: &str) -> String {
let (border, bg, text) = match kind {
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
_ => ("rose-700/50", "rose-900/30", "rose-300"),
};
format!(
r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
_ => {
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
+157 -13
View File
@@ -90,6 +90,34 @@ async fn set_email_inline(
// ---------- per-row actions ---------- // ---------- per-row actions ----------
#[derive(Debug, Deserialize)]
pub struct UpdateInfoForm {
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub email: String,
}
pub async fn update_info(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<UpdateInfoForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.user_set_display_name(id, form.display_name.trim())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
state
.db
.raw_update_user_email(id, form.email.trim())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(&state, "ok", "Profile updated.").await
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct PasswordResetForm { pub struct PasswordResetForm {
pub password: String, pub password: String,
@@ -102,6 +130,24 @@ pub async fn reset_password(
Form(form): Form<PasswordResetForm>, Form(form): Form<PasswordResetForm>,
) -> Result<Html<String>, ApiError> { ) -> Result<Html<String>, ApiError> {
require_admin(&admin)?; require_admin(&admin)?;
// Server-side guard: even though the UI hides the form for OIDC
// accounts, refuse to set a local password on them. Letting a local
// password slip in would silently re-enable password sign-in and
// bypass any MFA the IdP enforces.
let target = state
.db
.user_find_by_id(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
if target.is_oidc_linked() {
return notice_then_table(
&state,
"error",
"This account is linked to OIDC — set the password at the identity provider instead.",
)
.await;
}
if form.password.len() < 4 { if form.password.len() < 4 {
return notice_then_table( return notice_then_table(
&state, &state,
@@ -334,6 +380,13 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
totp.insert(u.id, b); totp.insert(u.id, b);
} }
} }
// Single GROUP BY query for the whole "last seen" column, derived
// from MAX(tokens.last_used_at) per user.
let last_seen = state
.db
.users_last_seen_map()
.await
.unwrap_or_default();
let mut s = String::new(); let mut s = String::new();
// No `overflow-hidden` on the table wrapper: the per-row action menu is // No `overflow-hidden` on the table wrapper: the per-row action menu is
// an absolutely-positioned `<details>` popover inside a <td>, and the // an absolutely-positioned `<details>` popover inside a <td>, and the
@@ -349,19 +402,25 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
<th class="text-left font-medium px-3 py-2">Status</th> <th class="text-left font-medium px-3 py-2">Status</th>
<th class="text-left font-medium px-3 py-2">Admin</th> <th class="text-left font-medium px-3 py-2">Admin</th>
<th class="text-left font-medium px-3 py-2">TOTP</th> <th class="text-left font-medium px-3 py-2">TOTP</th>
<th class="text-left font-medium px-3 py-2">Last seen</th>
<th class="text-right font-medium px-3 py-2 w-1">Actions</th> <th class="text-right font-medium px-3 py-2 w-1">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-800">"##, <tbody class="divide-y divide-slate-800">"##,
); );
for u in &users { for u in &users {
render_user_row(&mut s, u, *totp.get(&u.id).unwrap_or(&false)); render_user_row(
&mut s,
u,
*totp.get(&u.id).unwrap_or(&false),
last_seen.get(&u.id).map(String::as_str),
);
} }
s.push_str(" </tbody>\n</table></div>"); s.push_str(" </tbody>\n</table></div>");
Ok(s) Ok(s)
} }
fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) { fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Option<&str>) {
let status_badge = match u.status { let status_badge = match u.status {
1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-emerald-900/50 border border-emerald-700/50 text-emerald-300 text-xs">active</span>"#, 1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-emerald-900/50 border border-emerald-700/50 text-emerald-300 text-xs">active</span>"#,
0 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-400 text-xs">disabled</span>"#, 0 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-400 text-xs">disabled</span>"#,
@@ -373,11 +432,56 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
} else { } else {
"" ""
}; };
let totp_badge = if has_totp { // The TOTP column doubles as an "auth path" indicator: OIDC-linked
// users get an "OIDC" badge (their MFA lives at the IdP — local
// TOTP is moot), and OIDC takes precedence over local TOTP if both
// somehow exist.
let totp_badge = if u.is_oidc_linked() {
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-cyan-900/50 border border-cyan-700/50 text-cyan-300 text-xs">OIDC</span>"#
} else if has_totp {
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">enrolled</span>"# r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">enrolled</span>"#
} else { } else {
"" ""
}; };
let oidc_linked = u.is_oidc_linked();
// OIDC-linked users sign in via the IdP — adding a local password
// would let them bypass the IdP (and any MFA enforced there). Show
// a note instead of the password-reset form for these accounts.
let password_form = if oidc_linked {
r##"<div class="px-2 py-1.5 text-xs text-slate-500 italic border border-slate-800 rounded">
Linked to OIDC — password sign-in is disabled.
</div>"##
.to_string()
} else {
format!(
r##"<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset" hx-target="#users-region" hx-swap="innerHTML">
<input name="password" type="password" required minlength="4" placeholder="new password" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Set</button>
</form>"##,
id = u.id,
)
};
let (last_seen_rel, last_seen_abs) = match last_seen {
Some(ts) => (relative_ts(ts), html_escape(ts)),
None => ("never".to_string(), String::new()),
};
// TOTP enrollment is self-service (the user does it on their
// profile page so they can scan the QR + verify a code before
// we store the secret). Admin-side action is reset/disable only,
// and only relevant when the user has it enrolled.
let totp_button = if has_totp {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/users/{id}/totp-unenroll" hx-target="#users-region" hx-swap="innerHTML"
hx-confirm="Disable TOTP for {username}? They'll be able to sign in without a 6-digit code until they re-enroll.">
Disable TOTP
</button>"##,
id = u.id,
username = html_escape(&u.username),
)
} else {
String::new()
};
let _ = write!( let _ = write!(
s, s,
r##"<tr class="hover:bg-slate-800/40"> r##"<tr class="hover:bg-slate-800/40">
@@ -387,14 +491,17 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
<td class="px-3 py-2">{status}</td> <td class="px-3 py-2">{status}</td>
<td class="px-3 py-2">{admin}</td> <td class="px-3 py-2">{admin}</td>
<td class="px-3 py-2">{totp}</td> <td class="px-3 py-2">{totp}</td>
<td class="px-3 py-2 text-slate-400 whitespace-nowrap" title="{last_seen_abs}">{last_seen_rel}</td>
<td class="px-3 py-2"> <td class="px-3 py-2">
<details class="text-right relative"> <details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary> <summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left"> <div class="absolute right-2 mt-1 z-10 w-64 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset" hx-target="#users-region" hx-swap="innerHTML"> <form class="space-y-1" hx-post="/admin/pages/users/{id}/update-info" hx-target="#users-region" hx-swap="innerHTML">
<input name="password" type="password" required minlength="4" placeholder="new password" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/> <input name="display_name" value="{display_name}" placeholder="display name" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Set</button> <input name="email" type="email" value="{email}" placeholder="email" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
<button class="w-full bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Save profile</button>
</form> </form>
{password_form}
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/users/{id}/toggle-admin" hx-target="#users-region" hx-swap="innerHTML"> hx-post="/admin/pages/users/{id}/toggle-admin" hx-target="#users-region" hx-swap="innerHTML">
{admin_label} {admin_label}
@@ -403,10 +510,7 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
hx-post="/admin/pages/users/{id}/toggle-status" hx-target="#users-region" hx-swap="innerHTML"> hx-post="/admin/pages/users/{id}/toggle-status" hx-target="#users-region" hx-swap="innerHTML">
{status_label} {status_label}
</button> </button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" {totp_button}
hx-post="/admin/pages/users/{id}/totp-{totp_action}" hx-target="#users-region" hx-swap="innerHTML">
{totp_label}
</button>
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/30 rounded" <button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/30 rounded"
hx-post="/admin/pages/users/{id}/delete" hx-post="/admin/pages/users/{id}/delete"
hx-confirm="Delete user {username}? This cascades into their tokens, group memberships and AB shares." hx-confirm="Delete user {username}? This cascades into their tokens, group memberships and AB shares."
@@ -426,11 +530,51 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
totp = totp_badge, totp = totp_badge,
admin_label = if u.is_admin { "Revoke admin" } else { "Grant admin" }, admin_label = if u.is_admin { "Revoke admin" } else { "Grant admin" },
status_label = if u.status == 1 { "Disable user" } else { "Enable user" }, status_label = if u.status == 1 { "Disable user" } else { "Enable user" },
totp_action = if has_totp { "unenroll" } else { "enroll" }, totp_button = totp_button,
totp_label = if has_totp { "Disable TOTP" } else { "Enroll TOTP" }, last_seen_rel = last_seen_rel,
last_seen_abs = last_seen_abs,
password_form = password_form,
); );
} }
/// Format a SQLite `current_timestamp` string ("YYYY-MM-DD HH:MM:SS",
/// always UTC) as a relative time-ago label. Renders short forms — "5m
/// ago", "3h ago", "2d ago" — for the at-a-glance column; the absolute
/// timestamp goes into the cell's `title=` for hover.
fn relative_ts(ts: &str) -> String {
let parsed = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S")
.map(|t| t.and_utc())
.ok();
let Some(t) = parsed else {
return ts.to_string();
};
let now = chrono::Utc::now();
let secs = (now - t).num_seconds();
if secs < 0 {
return "just now".to_string();
}
if secs < 60 {
return "just now".to_string();
}
let mins = secs / 60;
if mins < 60 {
return format!("{}m ago", mins);
}
let hours = mins / 60;
if hours < 24 {
return format!("{}h ago", hours);
}
let days = hours / 24;
if days < 30 {
return format!("{}d ago", days);
}
let months = days / 30;
if months < 12 {
return format!("{}mo ago", months);
}
format!("{}y ago", months / 12)
}
fn notice_html(kind: &str, msg: &str) -> String { fn notice_html(kind: &str, msg: &str) -> String {
let (border, bg, text) = match kind { let (border, bg, text) = match kind {
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"), "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
+66 -6
View File
@@ -58,6 +58,24 @@ pub struct UserRow {
pub avatar: String, pub avatar: String,
pub status: i64, pub status: i64,
pub is_admin: bool, 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> { pub struct NewUser<'a> {
@@ -417,7 +435,7 @@ impl Database {
pub async fn user_find_by_username(&self, username: &str) -> ResultType<Option<UserRow>> { pub async fn user_find_by_username(&self, username: &str) -> ResultType<Option<UserRow>> {
let row = sqlx::query( 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 = ?", FROM users WHERE username = ?",
) )
.bind(username) .bind(username)
@@ -428,7 +446,7 @@ impl Database {
pub async fn user_find_by_id(&self, id: i64) -> ResultType<Option<UserRow>> { pub async fn user_find_by_id(&self, id: i64) -> ResultType<Option<UserRow>> {
let row = sqlx::query( 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 = ?", FROM users WHERE id = ?",
) )
.bind(id) .bind(id)
@@ -441,6 +459,32 @@ impl Database {
/// `users_list_accessible`, which the API uses (filtering by status=1 /// `users_list_accessible`, which the API uses (filtering by status=1
/// and visibility through device-groups). The dashboard wants the /// and visibility through device-groups). The dashboard wants the
/// full picture. /// 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( pub async fn users_list_all(
&self, &self,
offset: i64, offset: i64,
@@ -451,7 +495,7 @@ impl Database {
.await? .await?
.try_get("c")?; .try_get("c")?;
let rows = sqlx::query( 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 ?", FROM users ORDER BY username LIMIT ? OFFSET ?",
) )
.bind(limit) .bind(limit)
@@ -882,6 +926,21 @@ impl Database {
Ok(()) 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> { pub async fn user_has_totp(&self, user_id: i64) -> ResultType<bool> {
let row = let row =
sqlx::query("SELECT 1 AS ok FROM user_totp_secrets WHERE user_id = ?") 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 { let (count_sql, list_sql): (&str, String) = if is_admin {
( (
"SELECT COUNT(*) AS c FROM users WHERE status = 1", "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(), FROM users WHERE status = 1 ORDER BY username LIMIT ? OFFSET ?".to_string(),
) )
} else { } else {
@@ -2537,7 +2596,7 @@ impl Database {
email: Option<&str>, email: Option<&str>,
) -> ResultType<Option<UserRow>> { ) -> ResultType<Option<UserRow>> {
let row = sqlx::query( 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", FROM users WHERE oidc_subject = ? LIMIT 1",
) )
.bind(oidc_subject) .bind(oidc_subject)
@@ -2618,7 +2677,7 @@ impl Database {
pub async fn user_find_by_email(&self, email: &str) -> ResultType<Option<UserRow>> { pub async fn user_find_by_email(&self, email: &str) -> ResultType<Option<UserRow>> {
let row = sqlx::query( 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", FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
) )
.bind(email) .bind(email)
@@ -2735,6 +2794,7 @@ fn row_to_user(row: sqlx::sqlite::SqliteRow) -> UserRow {
avatar: row.try_get("avatar").unwrap_or_default(), avatar: row.try_get("avatar").unwrap_or_default(),
status: row.try_get("status").unwrap_or(1), status: row.try_get("status").unwrap_or(1),
is_admin: is_admin != 0, is_admin: is_admin != 0,
oidc_subject: row.try_get::<Option<String>, _>("oidc_subject").ok().flatten(),
} }
} }