//! 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 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)?; 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); } } 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)); } s.push_str(" \n
Username Display name Email Status Admin TOTP Actions
"); Ok(s) } fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) { 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 { "" }; let totp_badge = if has_totp { r#"enrolled"# } else { "" }; let _ = write!( s, r##" {username} {display_name} {email} {status} {admin} {totp}
···
"##, 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_action = if has_totp { "unenroll" } else { "enroll" }, totp_label = if has_totp { "Disable TOTP" } else { "Enroll TOTP" }, ); } 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())) } }