//! Users page — list / create / set-password / toggle-admin / toggle-status //! / TOTP enroll-unenroll / delete. use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; use crate::api::users::hash_password; use crate::database::{NewUser, UserRow}; use axum::extract::{Extension, Form, Path}; use axum::response::Html; use serde::Deserialize; use std::fmt::Write as _; use std::sync::Arc; use totp_rs::Secret; const PAGE_SIZE: i64 = 50; // ---------- index page ---------- pub async fn index( Extension(state): Extension>, admin: AuthedUser, ) -> Result, ApiError> { require_admin(&admin)?; Ok(Html(render_full_page(&state).await?)) } // ---------- create ---------- #[derive(Debug, Deserialize)] pub struct CreateForm { pub username: String, #[serde(default)] pub display_name: String, #[serde(default)] pub email: String, pub password: String, /// Checkbox: "on" if checked, absent otherwise. #[serde(default)] pub is_admin: Option, } pub async fn create( Extension(state): Extension>, admin: AuthedUser, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; if form.username.trim().is_empty() { return notice_then_table(&state, "error", "Username required").await; } if form.password.len() < 4 { return notice_then_table( &state, "error", "Password must be at least 4 characters", ) .await; } let hash = hash_password(form.password) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let id = state .db .user_insert(NewUser { username: form.username.trim(), password_hash: &hash, display_name: form.display_name.trim(), is_admin: form.is_admin.as_deref() == Some("on"), }) .await .map_err(|e| ApiError::Internal(format!("user_insert: {}", e)))?; if !form.email.trim().is_empty() { let _ = set_email_inline(&state, id, form.email.trim()).await; } notice_then_table(&state, "ok", &format!("Created user '{}'.", form.username)).await } async fn set_email_inline( state: &Arc, user_id: i64, email: &str, ) -> Result<(), String> { state .db .raw_update_user_email(user_id, email) .await .map_err(|e| e.to_string()) } // ---------- 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>, admin: AuthedUser, Path(id): Path, Form(form): Form, ) -> Result, 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)] pub struct PasswordResetForm { pub password: String, } pub async fn reset_password( Extension(state): Extension>, admin: AuthedUser, Path(id): Path, Form(form): Form, ) -> Result, ApiError> { 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 { return notice_then_table( &state, "error", "Password must be at least 4 characters", ) .await; } let hash = hash_password(form.password) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let ok = state .db .user_set_password(id, &hash) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then_table( &state, if ok { "ok" } else { "error" }, if ok { "Password updated." } else { "User not found." }, ) .await } pub async fn toggle_admin( Extension(state): Extension>, admin: AuthedUser, Path(id): Path, ) -> Result, ApiError> { require_admin(&admin)?; if id == admin.user_id { return notice_then_table( &state, "error", "You can't revoke your own admin flag here. Edit another admin's row instead.", ) .await; } let user = state .db .user_find_by_id(id) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound)?; state .db .user_set_admin(id, !user.is_admin) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Html(render_table(&state).await?)) } pub async fn toggle_status( Extension(state): Extension>, admin: AuthedUser, Path(id): Path, ) -> Result, ApiError> { require_admin(&admin)?; if id == admin.user_id { return notice_then_table( &state, "error", "You can't disable your own account from here.", ) .await; } let user = state .db .user_find_by_id(id) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound)?; let new_status: i64 = if user.status == 1 { 0 } else { 1 }; state .db .user_set_status(id, new_status) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Html(render_table(&state).await?)) } pub async fn delete( Extension(state): Extension>, admin: AuthedUser, Path(id): Path, ) -> Result, ApiError> { require_admin(&admin)?; if id == admin.user_id { return notice_then_table( &state, "error", "You can't delete the account you're signed in with.", ) .await; } let ok = state .db .user_delete(id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then_table( &state, if ok { "ok" } else { "error" }, if ok { "User deleted." } else { "Already gone." }, ) .await } // ---------- TOTP ---------- pub async fn totp_enroll( Extension(state): Extension>, admin: AuthedUser, Path(id): Path, ) -> Result, ApiError> { require_admin(&admin)?; let user = state .db .user_find_by_id(id) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound)?; let raw = sodiumoxide::randombytes::randombytes(20); let secret_b32 = Secret::Raw(raw).to_encoded().to_string(); state .db .totp_enroll(id, &secret_b32) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let issuer = "RustDesk"; let otpauth = format!( "otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30", issuer = url_encode(issuer), account = url_encode(&user.username), secret = url_encode(&secret_b32), ); let mut html = format!( r##"
TOTP enrolled for {user}
Secret (base32): {secret}
otpauth URL: {otpauth}
Show this once to the user (or scan the URL as a QR code) — it isn't displayed again.
"##, user = html_escape(&user.username), secret = html_escape(&secret_b32), otpauth = html_escape(&otpauth), ); html.push_str(&render_table(&state).await?); Ok(Html(html)) } pub async fn totp_unenroll( Extension(state): Extension>, admin: AuthedUser, Path(id): Path, ) -> Result, ApiError> { require_admin(&admin)?; let removed = state .db .totp_unenroll(id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then_table( &state, if removed { "ok" } else { "error" }, if removed { "TOTP removed." } else { "User had no TOTP." }, ) .await } // ---------- rendering helpers ---------- async fn notice_then_table( state: &Arc, kind: &str, msg: &str, ) -> Result, ApiError> { let mut html = notice_html(kind, msg); html.push_str(&render_table(state).await?); Ok(Html(html)) } async fn render_full_page(state: &Arc) -> Result { let table = render_table(state).await?; Ok(format!( r##"

Users

Create user

{table}
"## )) } async fn render_table(state: &Arc) -> Result { let (_total, users) = state .db .users_list_all(0, PAGE_SIZE) .await .map_err(|e| ApiError::Internal(e.to_string()))?; // One small query per row for the TOTP-enrolled flag — N is small. let mut totp = std::collections::HashMap::new(); for u in &users { if let Ok(b) = state.db.user_has_totp(u.id).await { 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(); // No `overflow-hidden` on the table wrapper: the per-row action menu is // an absolutely-positioned `
` popover inside a , and the // wrapper's clipping was hiding the bottom half of the menu. s.push_str( r##"
"##, ); for u in &users { 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(" \n
Username Display name Email Status Admin TOTP Last seen Actions
"); Ok(s) } fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Option<&str>) { let status_badge = match u.status { 1 => r#"active"#, 0 => r#"disabled"#, -1 => r#"unverified"#, _ => "", }; let admin_badge = if u.is_admin { r#"admin"# } else { "" }; // 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#"OIDC"# } else if has_totp { r#"enrolled"# } 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##"
Linked to OIDC — password sign-in is disabled.
"## .to_string() } else { format!( r##"
"##, 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##""##, id = u.id, username = html_escape(&u.username), ) } else { String::new() }; let _ = write!( s, r##" {username} {display_name} {email} {status} {admin} {totp} {last_seen_rel}
···
{password_form} {totp_button}
"##, id = u.id, username = html_escape(&u.username), display_name = html_escape(&u.display_name), email = html_escape(&u.email), status = status_badge, admin = admin_badge, totp = totp_badge, admin_label = if u.is_admin { "Revoke admin" } else { "Grant admin" }, status_label = if u.status == 1 { "Disable user" } else { "Enable user" }, totp_button = totp_button, 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 { 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##"
{msg}
"##, border = border, bg = bg, text = text, msg = html_escape(msg), ) } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } 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 } fn require_admin(u: &AuthedUser) -> Result<(), ApiError> { if u.is_admin { Ok(()) } else { Err(ApiError::Forbidden("admin required".into())) } }