From 840958aa0c4536d0e0ff64e9e024585c50da9752 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 22 May 2026 18:40:40 +0200 Subject: [PATCH] Implement filters and column management in admin UI lists --- admin_ui/index.html | 101 +++- src/api/admin/i18n.rs | 120 +++++ src/api/admin/mod.rs | 21 + src/api/admin/pages/devices.rs | 775 ++++++++++++++++++++++++++---- src/api/admin/pages/users.rs | 843 +++++++++++++++++++++++++++++---- src/api/device_auth.rs | 36 +- src/database.rs | 176 +++++++ 7 files changed, 1869 insertions(+), 203 deletions(-) diff --git a/admin_ui/index.html b/admin_ui/index.html index c2bde3d..c4c82c5 100644 --- a/admin_ui/index.html +++ b/admin_ui/index.html @@ -75,11 +75,24 @@
- + diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs index aad611d..cf535e4 100644 --- a/src/api/admin/i18n.rs +++ b/src/api/admin/i18n.rs @@ -350,6 +350,76 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Creează utilizator", "Crear usuario", ), + "users.add_user" => ( + "Add user", + "Benutzer hinzufügen", + "Ajouter un utilisateur", + "Adaugă utilizator", + "Añadir usuario", + ), + "users.new_user_heading" => ( + "New user", + "Neuer Benutzer", + "Nouvel utilisateur", + "Utilizator nou", + "Nuevo usuario", + ), + "users.columns" => ( + "Columns", + "Spalten", + "Colonnes", + "Coloane", + "Columnas", + ), + "users.per_page" => ( + "Per page", + "Pro Seite", + "Par page", + "Pe pagină", + "Por página", + ), + "users.showing_range" => ( + "Showing {0}\u{2013}{1} of {2}", + "{0}\u{2013}{1} von {2}", + "{0}\u{2013}{1} sur {2}", + "{0}\u{2013}{1} din {2}", + "{0}\u{2013}{1} de {2}", + ), + "users.no_users" => ( + "No users yet.", + "Noch keine Benutzer.", + "Aucun utilisateur pour l'instant.", + "Niciun utilizator încă.", + "Aún no hay usuarios.", + ), + "users.no_match" => ( + "No users match the search.", + "Keine Benutzer entsprechen der Suche.", + "Aucun utilisateur ne correspond à la recherche.", + "Niciun utilizator nu corespunde căutării.", + "Ningún usuario coincide con la búsqueda.", + ), + "users.search_placeholder" => ( + "Search users\u{2026}", + "Benutzer suchen\u{2026}", + "Rechercher des utilisateurs\u{2026}", + "Caută utilizatori\u{2026}", + "Buscar usuarios\u{2026}", + ), + "common.prev" => ( + "Previous", + "Zurück", + "Précédent", + "Anterior", + "Anterior", + ), + "common.next" => ( + "Next", + "Weiter", + "Suivant", + "Următor", + "Siguiente", + ), "users.username" => ( "username", "Benutzername", @@ -703,6 +773,48 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Niciun dispozitiv nu a trimis încă heartbeat.", "Ningún dispositivo ha enviado heartbeat aún.", ), + "devices.no_match" => ( + "No devices match the search.", + "Keine Geräte entsprechen der Suche.", + "Aucun appareil ne correspond à la recherche.", + "Niciun dispozitiv nu corespunde căutării.", + "Ningún dispositivo coincide con la búsqueda.", + ), + "devices.search_placeholder" => ( + "Search devices\u{2026}", + "Geräte suchen\u{2026}", + "Rechercher des appareils\u{2026}", + "Caută dispozitive\u{2026}", + "Buscar dispositivos\u{2026}", + ), + "devices.showing_range" => ( + "Showing {0}\u{2013}{1} of {2}", + "{0}\u{2013}{1} von {2}", + "{0}\u{2013}{1} sur {2}", + "{0}\u{2013}{1} din {2}", + "{0}\u{2013}{1} de {2}", + ), + "devices.columns" => ( + "Columns", + "Spalten", + "Colonnes", + "Coloane", + "Columnas", + ), + "devices.per_page" => ( + "Per page", + "Pro Seite", + "Par page", + "Pe pagină", + "Por página", + ), + "devices.col_serial" => ( + "Serial", + "Seriennr.", + "N° de série", + "Nr. serie", + "N.º de serie", + ), "devices.online" => ( "Online — last heartbeat {0}s ago", "Online — letzter Heartbeat vor {0}s", @@ -2347,3 +2459,11 @@ pub fn tf1(lang: Lang, key: &str, a: &str) -> String { pub fn tf2(lang: Lang, key: &str, a: &str, b: &str) -> String { t(lang, key).replace("{0}", a).replace("{1}", b) } + +/// Three-arg formatted lookup. Replaces `{0}`, `{1}`, and `{2}`. +pub fn tf3(lang: Lang, key: &str, a: &str, b: &str, c: &str) -> String { + t(lang, key) + .replace("{0}", a) + .replace("{1}", b) + .replace("{2}", c) +} diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 61dccd0..141179e 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -66,6 +66,19 @@ pub fn build(state: Arc) -> Option { .route("/admin/login/oidc/:provider", get(oidc_login::start_login)) // Page fragments — one per sidebar entry. .route("/admin/pages/users", get(pages::users::index)) + .route( + "/admin/pages/users/table-fragment", + get(pages::users::table_fragment), + ) + .route( + "/admin/pages/users/columns", + post(pages::users::set_columns), + ) + .route( + "/admin/pages/users/page-size", + post(pages::users::set_page_size), + ) + .route("/admin/pages/users/new", get(pages::users::new_form)) .route("/admin/pages/users/create", post(pages::users::create)) .route( "/admin/pages/users/:id/update-info", @@ -97,6 +110,14 @@ pub fn build(state: Arc) -> Option { "/admin/pages/devices/list-fragment", get(pages::devices::list_fragment), ) + .route( + "/admin/pages/devices/columns", + post(pages::devices::set_columns), + ) + .route( + "/admin/pages/devices/page-size", + post(pages::devices::set_page_size), + ) .route( "/admin/pages/devices/:peer_id/detail", get(pages::devices::detail), diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index f47caf7..4713de0 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -2,35 +2,191 @@ //! force-disconnect (queues a `heartbeat_commands` row consumed on the //! peer's next /api/heartbeat tick) and force-sysinfo refresh. -use crate::api::admin::i18n::{t, tf1, tf2, Lang}; +use crate::api::admin::i18n::{t, tf1, tf2, tf3, Lang}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; use crate::database::DashboardDeviceRow; -use axum::extract::{Extension, Path}; +use axum::extract::{Extension, Form, Path, Query}; use axum::response::Html; +use serde::Deserialize; +use std::collections::HashSet; use std::fmt::Write as _; use std::sync::Arc; -const PAGE_SIZE: i64 = 100; +// ---------- pagination / search prefs ---------- + +const PAGE_SIZE_DEFAULT: i64 = 100; +const PAGE_SIZE_OPTIONS: &[i64] = &[25, 50, 100, 500]; +const DEVICE_PAGE_SIZE_KEY: &str = "devices.page_size"; +const DEVICE_COLS_HIDE_KEY: &str = "devices.cols_hide"; /// Devices that have heartbeated within this many seconds are considered /// online. Clients heartbeat every 15s (hbb_common::config::REG_INTERVAL), /// so 45s allows up to two missed beats before we flip the dot to red. const ONLINE_THRESHOLD_SECS: i64 = 45; +#[derive(Debug, Deserialize, Default)] +pub struct DevicesListParams { + #[serde(default)] + pub page: Option, + #[serde(default)] + pub q: Option, +} + +impl DevicesListParams { + fn page_or_first(&self) -> i64 { + self.page.unwrap_or(1).max(1) + } + + fn query(&self) -> &str { + match &self.q { + Some(s) => s.trim(), + None => "", + } + } +} + +/// Per-user column visibility for the devices table. The peer-id cell +/// and actions menu aren't user-toggleable, so they're absent here. +#[derive(Clone, Copy, Debug)] +pub struct DeviceColumns { + pub owner: bool, + pub host: bool, + pub serial: bool, + pub user: bool, + pub unattended_pwd: bool, + pub os: bool, + pub version: bool, + pub last_heartbeat: bool, + pub conns: bool, + pub auth: bool, +} + +impl Default for DeviceColumns { + fn default() -> Self { + Self { + owner: true, + host: true, + serial: true, + user: true, + unattended_pwd: true, + os: true, + version: true, + last_heartbeat: true, + conns: true, + auth: true, + } + } +} + +impl DeviceColumns { + fn from_hidden_csv(raw: &str) -> Self { + let hidden: HashSet<&str> = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + Self { + owner: !hidden.contains("owner"), + host: !hidden.contains("host"), + serial: !hidden.contains("serial"), + user: !hidden.contains("user"), + unattended_pwd: !hidden.contains("unattended_pwd"), + os: !hidden.contains("os"), + version: !hidden.contains("version"), + last_heartbeat: !hidden.contains("last_heartbeat"), + conns: !hidden.contains("conns"), + auth: !hidden.contains("auth"), + } + } + + fn to_hidden_csv(self) -> String { + let mut hidden: Vec<&str> = Vec::new(); + if !self.owner { hidden.push("owner"); } + if !self.host { hidden.push("host"); } + if !self.serial { hidden.push("serial"); } + if !self.user { hidden.push("user"); } + if !self.unattended_pwd { hidden.push("unattended_pwd"); } + if !self.os { hidden.push("os"); } + if !self.version { hidden.push("version"); } + if !self.last_heartbeat { hidden.push("last_heartbeat"); } + if !self.conns { hidden.push("conns"); } + if !self.auth { hidden.push("auth"); } + hidden.join(",") + } + + fn visible_count(self) -> i64 { + self.owner as i64 + + self.host as i64 + + self.serial as i64 + + self.user as i64 + + self.unattended_pwd as i64 + + self.os as i64 + + self.version as i64 + + self.last_heartbeat as i64 + + self.conns as i64 + + self.auth as i64 + } +} + +async fn load_device_columns(state: &Arc, user_id: i64) -> DeviceColumns { + match state.db.user_pref_get(user_id, DEVICE_COLS_HIDE_KEY).await { + Ok(Some(raw)) => DeviceColumns::from_hidden_csv(&raw), + _ => DeviceColumns::default(), + } +} + +async fn load_device_page_size(state: &Arc, user_id: i64) -> i64 { + let raw = state + .db + .user_pref_get(user_id, DEVICE_PAGE_SIZE_KEY) + .await + .ok() + .flatten() + .and_then(|s| s.parse::().ok()); + match raw { + Some(v) if PAGE_SIZE_OPTIONS.contains(&v) => v, + _ => PAGE_SIZE_DEFAULT, + } +} + pub async fn index( Extension(state): Extension>, admin: AuthedUser, lang: Lang, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; - let table = render_table(&state, lang).await?; + let cols = load_device_columns(&state, admin.user_id).await; + let page_size = load_device_page_size(&state, admin.user_id).await; + let page = pg.page_or_first(); + let q = pg.query(); + let table = render_table(&state, lang, cols, q, page, page_size).await?; + let columns_popover = render_columns_popover(lang, cols); Ok(Html(format!( r##"
-
-

{heading}

-

{tagline}

+
+
+

{heading}

+

{tagline}

+
+
+ + {columns_popover} +
{table} @@ -38,6 +194,9 @@ pub async fn index(
"##, heading = t(lang, "devices.heading"), tagline = t(lang, "devices.tagline"), + search_placeholder = t(lang, "devices.search_placeholder"), + q_value = html_escape(q), + columns_popover = columns_popover, table = table, ))) } @@ -47,8 +206,11 @@ pub async fn force_disconnect( admin: AuthedUser, lang: Lang, Path(peer_id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_device_columns(&state, admin.user_id).await; + let page_size = load_device_page_size(&state, admin.user_id).await; let conns = state .db .device_sysinfo_get_conns(&peer_id) @@ -62,6 +224,10 @@ pub async fn force_disconnect( notice_then_table( &state, lang, + cols, + pg.query(), + pg.page_or_first(), + page_size, "ok", &tf2(lang, "devices.queued_disconnect", &peer_id, &conns), ) @@ -73,8 +239,11 @@ pub async fn force_sysinfo( admin: AuthedUser, lang: Lang, Path(peer_id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_device_columns(&state, admin.user_id).await; + let page_size = load_device_page_size(&state, admin.user_id).await; state .db .heartbeat_command_queue(&peer_id, "sysinfo", None) @@ -83,6 +252,10 @@ pub async fn force_sysinfo( notice_then_table( &state, lang, + cols, + pg.query(), + pg.page_or_first(), + page_size, "ok", &tf1(lang, "devices.queued_sysinfo", &peer_id), ) @@ -94,8 +267,11 @@ pub async fn delete( admin: AuthedUser, lang: Lang, Path(peer_id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_device_columns(&state, admin.user_id).await; + let page_size = load_device_page_size(&state, admin.user_id).await; let ok = state .db .device_delete(&peer_id) @@ -106,7 +282,17 @@ pub async fn delete( } else { t(lang, "devices.already_gone").to_string() }; - notice_then_table(&state, lang, if ok { "ok" } else { "error" }, &msg).await + notice_then_table( + &state, + lang, + cols, + pg.query(), + pg.page_or_first(), + page_size, + if ok { "ok" } else { "error" }, + &msg, + ) + .await } /// Flip `peer.managed` between 0 and 1. Same effect as calling the JSON @@ -120,8 +306,13 @@ pub async fn toggle_managed( admin: AuthedUser, lang: Lang, Path(peer_id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_device_columns(&state, admin.user_id).await; + let page_size = load_device_page_size(&state, admin.user_id).await; + let q = pg.query(); + let page = pg.page_or_first(); let row = state .db .peer_get_auth(&peer_id) @@ -133,6 +324,10 @@ pub async fn toggle_managed( return notice_then_table( &state, lang, + cols, + q, + page, + page_size, "error", &tf1(lang, "devices.managed_no_peer", &peer_id), ) @@ -156,7 +351,94 @@ pub async fn toggle_managed( } else { "devices.managed_now_off" }; - notice_then_table(&state, lang, "ok", &tf1(lang, key, &peer_id)).await + notice_then_table( + &state, + lang, + cols, + q, + page, + page_size, + "ok", + &tf1(lang, key, &peer_id), + ) + .await +} + +// ---------- column visibility / page size POST handlers ---------- + +#[derive(Debug, Deserialize)] +pub struct ColumnsForm { + pub col: String, + pub visible: String, + #[serde(default)] + pub q: Option, +} + +pub async fn set_columns( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + let mut cols = load_device_columns(&state, admin.user_id).await; + let visible = form.visible == "1" || form.visible.eq_ignore_ascii_case("true"); + match form.col.as_str() { + "owner" => cols.owner = visible, + "host" => cols.host = visible, + "serial" => cols.serial = visible, + "user" => cols.user = visible, + "unattended_pwd" => cols.unattended_pwd = visible, + "os" => cols.os = visible, + "version" => cols.version = visible, + "last_heartbeat" => cols.last_heartbeat = visible, + "conns" => cols.conns = visible, + "auth" => cols.auth = visible, + _ => {} + } + state + .db + .user_pref_set(admin.user_id, DEVICE_COLS_HIDE_KEY, &cols.to_hidden_csv()) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let page_size = load_device_page_size(&state, admin.user_id).await; + let q = form.q.as_deref().unwrap_or("").trim(); + Ok(Html( + render_table(&state, lang, cols, q, 1, page_size).await?, + )) +} + +#[derive(Debug, Deserialize)] +pub struct PageSizeForm { + pub size: i64, + #[serde(default)] + pub q: Option, +} + +pub async fn set_page_size( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + if !PAGE_SIZE_OPTIONS.contains(&form.size) { + return Err(ApiError::BadRequest("invalid page size".into())); + } + state + .db + .user_pref_set( + admin.user_id, + DEVICE_PAGE_SIZE_KEY, + &form.size.to_string(), + ) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let cols = load_device_columns(&state, admin.user_id).await; + let q = form.q.as_deref().unwrap_or("").trim(); + Ok(Html( + render_table(&state, lang, cols, q, 1, form.size).await?, + )) } /// Per-device detail page: hardware / OS inventory reported by hello-agent @@ -213,10 +495,28 @@ fn online_state(last_heartbeat_at: &str, now: chrono::DateTime) -> } } -async fn render_table(state: &Arc, lang: Lang) -> Result { - let (total, devices) = state +async fn render_table( + state: &Arc, + lang: Lang, + cols: DeviceColumns, + q: &str, + page: i64, + page_size: i64, +) -> Result { + // Probe the filtered total so we can clamp the requested page before + // doing the offset query (e.g. asking for page 5 after a delete that + // left only 1 page should land on page 1, not show an empty list). + let (total, _) = state .db - .devices_list_all(0, PAGE_SIZE) + .devices_list_filtered(q, 0, 0) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let total_pages = if total == 0 { 1 } else { (total + page_size - 1) / page_size }; + let page = page.clamp(1, total_pages); + let offset = (page - 1) * page_size; + let (_, devices) = state + .db + .devices_list_filtered(q, offset, page_size) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let now = chrono::Utc::now(); @@ -230,52 +530,222 @@ async fn render_table(state: &Arc, lang: Lang) -> Result - {c_peer} - {c_owner} - {c_host} - {c_user} - {c_pwd} - {c_os} - {c_ver} - {c_last} - {c_conns} - {c_auth} + {c_peer}"##, + c_peer = t(lang, "devices.col_peer_id"), + ); + if cols.owner { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_owner")); + } + if cols.host { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_hostname")); + } + if cols.serial { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_serial")); + } + if cols.user { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_user")); + } + if cols.unattended_pwd { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_unattended_pwd")); + } + if cols.os { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_os")); + } + if cols.version { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_version")); + } + if cols.last_heartbeat { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_last_heartbeat")); + } + if cols.conns { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_conns")); + } + if cols.auth { + let _ = write!(s, r##" + {}"##, t(lang, "devices.col_auth")); + } + let _ = write!( + s, + r##" {c_actions} "##, - c_peer = t(lang, "devices.col_peer_id"), - c_owner = t(lang, "devices.col_owner"), - c_host = t(lang, "devices.col_hostname"), - c_user = t(lang, "devices.col_user"), - c_pwd = t(lang, "devices.col_unattended_pwd"), - c_os = t(lang, "devices.col_os"), - c_ver = t(lang, "devices.col_version"), - c_last = t(lang, "devices.col_last_heartbeat"), - c_conns = t(lang, "devices.col_conns"), - c_auth = t(lang, "devices.col_auth"), c_actions = t(lang, "common.actions"), ); if devices.is_empty() { + // colspan = peer column + visible toggle columns + actions column. + let colspan = 1 + cols.visible_count() + 1; + let empty_msg = if q.is_empty() { + t(lang, "devices.no_devices") + } else { + t(lang, "devices.no_match") + }; let _ = write!( s, - r##"{}"##, - t(lang, "devices.no_devices"), + r##"{empty}"##, + colspan = colspan, + empty = empty_msg, + ); + } else { + let always_show_pwd = unattended_pwd_always_visible(); + for d in &devices { + render_device_row(&mut s, lang, cols, q, page, d, now, always_show_pwd); + } + } + s.push_str("\n"); + s.push_str(&render_pagination_footer(lang, q, page, page_size, total, total_pages)); + Ok(s) +} + +fn render_columns_popover(lang: Lang, cols: DeviceColumns) -> String { + let items: [(&str, bool, &str); 10] = [ + ("owner", cols.owner, t(lang, "devices.col_owner")), + ("host", cols.host, t(lang, "devices.col_hostname")), + ("serial", cols.serial, t(lang, "devices.col_serial")), + ("user", cols.user, t(lang, "devices.col_user")), + ("unattended_pwd", cols.unattended_pwd, t(lang, "devices.col_unattended_pwd")), + ("os", cols.os, t(lang, "devices.col_os")), + ("version", cols.version, t(lang, "devices.col_version")), + ("last_heartbeat", cols.last_heartbeat, t(lang, "devices.col_last_heartbeat")), + ("conns", cols.conns, t(lang, "devices.col_conns")), + ("auth", cols.auth, t(lang, "devices.col_auth")), + ]; + let mut checkboxes = String::new(); + for (id, visible, label) in items { + let checked = if visible { " checked" } else { "" }; + let _ = write!( + checkboxes, + r##""##, + id = id, + checked = checked, + label = html_escape(label), ); } - let always_show_pwd = unattended_pwd_always_visible(); - for d in &devices { - render_device_row(&mut s, lang, d, now, always_show_pwd); - } - let _ = write!( - s, - r##" - -
{count}
-"##, - count = tf1(lang, "devices.devices_count", &total.to_string()), + format!( + r##"
+ + {columns_label} ▾ + +
+ {checkboxes} +
+
"##, + columns_label = t(lang, "devices.columns"), + checkboxes = checkboxes, + ) +} + +fn render_pagination_footer( + lang: Lang, + q: &str, + page: i64, + page_size: i64, + total: i64, + total_pages: i64, +) -> String { + let from = if total == 0 { 0 } else { (page - 1) * page_size + 1 }; + let to = ((page * page_size).min(total)).max(0); + let showing = tf3( + lang, + "devices.showing_range", + &from.to_string(), + &to.to_string(), + &total.to_string(), ); - Ok(s) + let mut size_options = String::new(); + for &n in PAGE_SIZE_OPTIONS { + let selected = if n == page_size { " selected" } else { "" }; + let _ = write!( + size_options, + r##""##, + n = n, + selected = selected, + ); + } + let q_param = if q.is_empty() { + String::new() + } else { + format!("&q={}", url_encode(q)) + }; + let prev_disabled = page <= 1; + let next_disabled = page >= total_pages; + let prev_attrs = if prev_disabled { + " disabled".to_string() + } else { + format!( + r##" hx-get="/admin/pages/devices/list-fragment?page={p}{q_param}" hx-target="#devices-region" hx-swap="innerHTML""##, + p = page - 1, + q_param = q_param, + ) + }; + let next_attrs = if next_disabled { + " disabled".to_string() + } else { + format!( + r##" hx-get="/admin/pages/devices/list-fragment?page={p}{q_param}" hx-target="#devices-region" hx-swap="innerHTML""##, + p = page + 1, + q_param = q_param, + ) + }; + let btn_base = "px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700 \ + disabled:opacity-40 disabled:hover:bg-slate-800 disabled:cursor-not-allowed"; + format!( + r##"
+
+ {showing} + +
+
+ + {page} / {total_pages} + +
+
"##, + showing = html_escape(&showing), + per_page_label = t(lang, "devices.per_page"), + size_options = size_options, + prev = t(lang, "common.prev"), + next = t(lang, "common.next"), + btn = btn_base, + prev_attrs = prev_attrs, + next_attrs = next_attrs, + page = page, + total_pages = total_pages, + ) +} + +/// Percent-encode characters that aren't safe in a URL query value. +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 } /// Resolves the `--unattended-pwd-visibility` setting (env key @@ -293,6 +763,9 @@ fn unattended_pwd_always_visible() -> bool { fn render_device_row( s: &mut String, lang: Lang, + cols: DeviceColumns, + q: &str, + page: i64, d: &DashboardDeviceRow, now: chrono::DateTime, always_show_pwd: bool, @@ -307,6 +780,15 @@ fn render_device_row( .to_string() }; let hostname = pick("hostname"); + // Hardware serial number — only present when the agent (e.g. + // hello-agent) ships an inventory blob alongside the sysinfo + // payload. Vanilla rustdesk doesn't, so most rows render a dash. + let serial_number = parsed + .get("inventory") + .and_then(|inv| inv.get("serial_number")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); // `username` is the active console user reported by the agent's // sysinfo. The agent suppresses the field when nobody is logged in // (or when it's literally "SYSTEM" on Windows), so an empty value @@ -385,28 +867,14 @@ fn render_device_row( id = html_escape(&d.id), ); - // Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat / - // sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when - // managed=0 and the device still posts unsigned bodies. The tooltip - // gives the operator the one-line explanation so they know what - // flipping the flag will do. - let auth_cell = if d.managed { - format!( - r##" - {label} - "##, - tt = html_escape(t(lang, "devices.auth_signed_tooltip")), - label = t(lang, "devices.auth_signed"), - ) + // `?page={page}&q={q}` appended to every per-row action so the + // re-rendered table comes back showing the same filtered page. + let q_param = if q.is_empty() { + String::new() } else { - format!( - r##" - {label} - "##, - tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")), - label = t(lang, "devices.auth_unsigned"), - ) + format!("&q={}", url_encode(q)) }; + // Auth toggle: the menu entry's label flips based on current state, // and only the off→on transition needs no confirm (it strengthens // security). on→off removes the signature requirement and reintroduces @@ -414,40 +882,147 @@ fn render_device_row( let toggle_managed_item = if d.managed { format!( r##""##, id = html_escape(&d.id), + page = page, + q_param = q_param, confirm = html_escape(&tf1(lang, "devices.confirm_managed_off", &d.id)), label = t(lang, "devices.mark_unsigned"), ) } else { format!( r##""##, id = html_escape(&d.id), + page = page, + q_param = q_param, label = t(lang, "devices.mark_managed"), ) }; + // Open + the always-on peer id cell. + let _ = write!(s, r##" + {id_cell}"##, id_cell = id_cell); + if cols.owner { + let _ = write!( + s, + r##" + {}"##, + html_escape(&d.owner_username), + ); + } + if cols.host { + let _ = write!( + s, + r##" + {}"##, + html_escape(&hostname), + ); + } + if cols.serial { + let cell = if serial_number.is_empty() { + r##""##.to_string() + } else { + format!( + r##"{}"##, + html_escape(&serial_number), + ) + }; + let _ = write!( + s, + r##" + {}"##, + cell, + ); + } + if cols.user { + let user_cell = if active_user.is_empty() { + "—".to_string() + } else { + html_escape(&active_user) + }; + let _ = write!( + s, + r##" + {}"##, + user_cell, + ); + } + if cols.unattended_pwd { + let _ = write!( + s, + r##" + {}"##, + unattended_pwd_cell, + ); + } + if cols.os { + let _ = write!( + s, + r##" + {}"##, + html_escape(&os), + ); + } + if cols.version { + let _ = write!( + s, + r##" + {}"##, + html_escape(&version_label), + ); + } + if cols.last_heartbeat { + let _ = write!( + s, + r##" + {}"##, + html_escape(&d.last_heartbeat_at), + ); + } + if cols.conns { + let _ = write!( + s, + r##" + {}"##, + conn_count, + ); + } + if cols.auth { + // Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat / + // sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when + // managed=0 and the device still posts unsigned bodies. The tooltip + // gives the operator the one-line explanation so they know what + // flipping the flag will do. + let auth_cell = if d.managed { + format!( + r##" + {label} + "##, + tt = html_escape(t(lang, "devices.auth_signed_tooltip")), + label = t(lang, "devices.auth_signed"), + ) + } else { + format!( + r##" + {label} + "##, + tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")), + label = t(lang, "devices.auth_unsigned"), + ) + }; + let _ = write!(s, "\n {}", auth_cell); + } let _ = write!( s, - r##" - {id_cell} - {owner} - {host} - {user} - {unattended_pwd} - {os} - {ver} - {last} - {n} - {auth_cell} + r##"
··· @@ -470,19 +1045,19 @@ fn render_device_row( {toggle_managed_item}

"##, - id_cell = id_cell, id = html_escape(&d.id), - owner = html_escape(&d.owner_username), - host = html_escape(&hostname), - user = if active_user.is_empty() { - "—".to_string() - } else { - html_escape(&active_user) - }, - unattended_pwd = unattended_pwd_cell, - os = html_escape(&os), - ver = html_escape(&version_label), - last = html_escape(&d.last_heartbeat_at), - n = conn_count, - auth_cell = auth_cell, + page = page, + q_param = q_param, toggle_managed_item = toggle_managed_item, connect_web = t(lang, "devices.connect_web"), details = t(lang, "devices.details"), @@ -522,16 +1085,22 @@ fn render_device_row( /// HTMX-only endpoint returning just the devices table fragment (no /// outer header), so the per-device detail view's "Back to devices" -/// button can swap the table back into `#devices-region` without -/// re-rendering the whole page wrapper. Same shape as -/// `notice_then_table` minus the notice banner. +/// button, the search input, and the pagination buttons can all swap +/// the table back into `#devices-region` without re-rendering the +/// whole page wrapper. Accepts `?page=N&q=...` so the same handler +/// drives both navigation and filtering. pub async fn list_fragment( Extension(state): Extension>, admin: AuthedUser, lang: Lang, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; - Ok(Html(render_table(&state, lang).await?)) + let cols = load_device_columns(&state, admin.user_id).await; + let page_size = load_device_page_size(&state, admin.user_id).await; + Ok(Html( + render_table(&state, lang, cols, pg.query(), pg.page_or_first(), page_size).await?, + )) } /// "Back to devices" — refetches the devices table fragment via HTMX @@ -1075,11 +1644,15 @@ fn fmt_age(secs: i64) -> String { async fn notice_then_table( state: &Arc, lang: Lang, + cols: DeviceColumns, + q: &str, + page: i64, + page_size: i64, kind: &str, msg: &str, ) -> Result, ApiError> { let mut html = notice_html(kind, msg); - html.push_str(&render_table(state, lang).await?); + html.push_str(&render_table(state, lang, cols, q, page, page_size).await?); Ok(Html(html)) } diff --git a/src/api/admin/pages/users.rs b/src/api/admin/pages/users.rs index 5c29968..d3111a5 100644 --- a/src/api/admin/pages/users.rs +++ b/src/api/admin/pages/users.rs @@ -1,20 +1,145 @@ //! Users page — list / create / set-password / toggle-admin / toggle-status //! / TOTP enroll-unenroll / delete. -use crate::api::admin::i18n::{t, tf1, Lang}; +use crate::api::admin::i18n::{t, tf1, tf3, Lang}; 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 axum::extract::{Extension, Form, Path, Query}; +use axum::http::{HeaderMap, HeaderValue}; +use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; +use std::collections::HashSet; use std::fmt::Write as _; use std::sync::Arc; use totp_rs::Secret; -const PAGE_SIZE: i64 = 50; +// ---------- pagination ---------- + +const PAGE_SIZE_DEFAULT: i64 = 100; +/// Allowed page sizes shown in the "Per page" selector. Anything else +/// sent by the client is rejected on the server so we don't end up +/// loading 1M-row pages by accident. +const PAGE_SIZE_OPTIONS: &[i64] = &[25, 50, 100, 500]; +const USER_PAGE_SIZE_KEY: &str = "users.page_size"; + +#[derive(Debug, Deserialize, Default)] +pub struct UsersListParams { + #[serde(default)] + pub page: Option, + #[serde(default)] + pub q: Option, +} + +impl UsersListParams { + /// 1-indexed page number with sensible bounds. + fn page_or_first(&self) -> i64 { + self.page.unwrap_or(1).max(1) + } + + /// Trimmed search query (empty when no filter is active). + fn query(&self) -> &str { + match &self.q { + Some(s) => s.trim(), + None => "", + } + } +} + +/// Load the user's saved page size, clamped to one of `PAGE_SIZE_OPTIONS`. +async fn load_user_page_size(state: &Arc, user_id: i64) -> i64 { + let raw = state + .db + .user_pref_get(user_id, USER_PAGE_SIZE_KEY) + .await + .ok() + .flatten() + .and_then(|s| s.parse::().ok()); + match raw { + Some(v) if PAGE_SIZE_OPTIONS.contains(&v) => v, + _ => PAGE_SIZE_DEFAULT, + } +} + +// ---------- column visibility prefs ---------- + +/// Per-user column visibility for the users table. Stored as a +/// comma-separated list of *hidden* column ids in the `user_prefs` table +/// under key `USER_COLS_HIDE_KEY`. Default (no row) = everything visible. +/// `username` and the actions menu aren't listed because they're not +/// user-toggleable. +#[derive(Clone, Copy, Debug)] +pub struct UserColumns { + pub display_name: bool, + pub email: bool, + pub status: bool, + pub admin: bool, + pub totp: bool, + pub last_seen: bool, +} + +impl Default for UserColumns { + fn default() -> Self { + Self { + display_name: true, + email: true, + status: true, + admin: true, + totp: true, + last_seen: true, + } + } +} + +const USER_COLS_HIDE_KEY: &str = "users.cols_hide"; + +impl UserColumns { + fn from_hidden_csv(raw: &str) -> Self { + let hidden: HashSet<&str> = raw.split(',').map(str::trim).filter(|s| !s.is_empty()).collect(); + Self { + display_name: !hidden.contains("display_name"), + email: !hidden.contains("email"), + status: !hidden.contains("status"), + admin: !hidden.contains("admin"), + totp: !hidden.contains("totp"), + last_seen: !hidden.contains("last_seen"), + } + } + + fn to_hidden_csv(self) -> String { + let mut hidden: Vec<&str> = Vec::new(); + if !self.display_name { + hidden.push("display_name"); + } + if !self.email { + hidden.push("email"); + } + if !self.status { + hidden.push("status"); + } + if !self.admin { + hidden.push("admin"); + } + if !self.totp { + hidden.push("totp"); + } + if !self.last_seen { + hidden.push("last_seen"); + } + hidden.join(",") + } +} + +/// Load the user's saved column visibility. Falls back to "all visible" +/// on any DB error so a transient hiccup doesn't blank the page. +async fn load_user_columns(state: &Arc, user_id: i64) -> UserColumns { + match state.db.user_pref_get(user_id, USER_COLS_HIDE_KEY).await { + Ok(Some(raw)) => UserColumns::from_hidden_csv(&raw), + _ => UserColumns::default(), + } +} // ---------- index page ---------- @@ -22,9 +147,111 @@ pub async fn index( Extension(state): Extension>, admin: AuthedUser, lang: Lang, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; - Ok(Html(render_full_page(&state, lang).await?)) + Ok(Html( + render_full_page(&state, lang, admin.user_id, pg.page_or_first(), pg.query()).await?, + )) +} + +/// Fragment endpoint for the `#users-region` block: re-renders just the +/// table + pagination footer. Used by the pagination buttons and the +/// search input so the page header (and the open "Columns" popover) +/// stays as-is. +pub async fn table_fragment( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Query(pg): Query, +) -> Result, ApiError> { + require_admin(&admin)?; + let cols = load_user_columns(&state, admin.user_id).await; + let page_size = load_user_page_size(&state, admin.user_id).await; + Ok(Html( + render_table(&state, lang, cols, pg.query(), pg.page_or_first(), page_size).await?, + )) +} + +#[derive(Debug, Deserialize)] +pub struct ColumnsForm { + pub col: String, + pub visible: String, + /// Search query in effect when the user toggled — preserved so the + /// re-rendered table still shows the filtered set. + #[serde(default)] + pub q: Option, +} + +/// Toggle a single column's visibility, persist the new prefs, and +/// return the updated table. The "Columns" popover in the page header +/// drives this — see `usersColumnToggle` in admin_ui/index.html. +pub async fn set_columns( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + let mut cols = load_user_columns(&state, admin.user_id).await; + let visible = form.visible == "1" || form.visible.eq_ignore_ascii_case("true"); + match form.col.as_str() { + "display_name" => cols.display_name = visible, + "email" => cols.email = visible, + "status" => cols.status = visible, + "admin" => cols.admin = visible, + "totp" => cols.totp = visible, + "last_seen" => cols.last_seen = visible, + // Silently ignore unknown column ids — older clients shouldn't + // be able to write garbage into the prefs row. + _ => {} + } + state + .db + .user_pref_set(admin.user_id, USER_COLS_HIDE_KEY, &cols.to_hidden_csv()) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let page_size = load_user_page_size(&state, admin.user_id).await; + let q = form.q.as_deref().unwrap_or("").trim(); + Ok(Html( + render_table(&state, lang, cols, q, 1, page_size).await?, + )) +} + +#[derive(Debug, Deserialize)] +pub struct PageSizeForm { + pub size: i64, + #[serde(default)] + pub q: Option, +} + +/// Persist the page-size choice and re-render the table at page 1. +/// Changing the size shifts which rows belong to which page, so jumping +/// back to page 1 is the predictable outcome. +pub async fn set_page_size( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + if !PAGE_SIZE_OPTIONS.contains(&form.size) { + return Err(ApiError::BadRequest("invalid page size".into())); + } + state + .db + .user_pref_set( + admin.user_id, + USER_PAGE_SIZE_KEY, + &form.size.to_string(), + ) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let cols = load_user_columns(&state, admin.user_id).await; + let q = form.q.as_deref().unwrap_or("").trim(); + Ok(Html( + render_table(&state, lang, cols, q, 1, form.size).await?, + )) } // ---------- create ---------- @@ -42,18 +269,43 @@ pub struct CreateForm { pub is_admin: Option, } +pub async fn new_form( + admin: AuthedUser, + lang: Lang, +) -> Result, ApiError> { + require_admin(&admin)?; + Ok(Html(render_new_user_page(lang, "", "", "", false, None))) +} + pub async fn create( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Form(form): Form, -) -> Result, ApiError> { +) -> Result { require_admin(&admin)?; + let is_admin_checked = form.is_admin.as_deref() == Some("on"); if form.username.trim().is_empty() { - return notice_then_table(&state, lang, "error", t(lang, "users.username_required")).await; + return Ok(Html(render_new_user_page( + lang, + &form.username, + &form.display_name, + &form.email, + is_admin_checked, + Some(t(lang, "users.username_required")), + )) + .into_response()); } if form.password.len() < 4 { - return notice_then_table(&state, lang, "error", t(lang, "users.password_min")).await; + return Ok(Html(render_new_user_page( + lang, + &form.username, + &form.display_name, + &form.email, + is_admin_checked, + Some(t(lang, "users.password_min")), + )) + .into_response()); } let hash = hash_password(form.password) .await @@ -64,14 +316,24 @@ pub async fn create( username: form.username.trim(), password_hash: &hash, display_name: form.display_name.trim(), - is_admin: form.is_admin.as_deref() == Some("on"), + is_admin: is_admin_checked, }) .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, lang, "ok", &tf1(lang, "users.created", &form.username)).await + // Success: navigate back to the users list with a success notice. + // HX-Push-Url updates the address bar hash so a reload lands on the + // list page rather than the now-stale form page. Start the list on + // page 1 so the freshly-created user is visible (the list is sorted + // by username, but page 1 is the natural landing spot regardless). + let notice = notice_html("ok", &tf1(lang, "users.created", &form.username)); + let mut body = notice; + body.push_str(&render_full_page(&state, lang, admin.user_id, 1, "").await?); + let mut headers = HeaderMap::new(); + headers.insert("HX-Push-Url", HeaderValue::from_static("#users")); + Ok((headers, Html(body)).into_response()) } async fn set_email_inline( @@ -101,9 +363,14 @@ pub async fn update_info( admin: AuthedUser, lang: Lang, Path(id): Path, + Query(pg): Query, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_user_columns(&state, admin.user_id).await; + let page_size = load_user_page_size(&state, admin.user_id).await; + let page = pg.page_or_first(); + let q = pg.query(); state .db .user_set_display_name(id, form.display_name.trim()) @@ -114,7 +381,7 @@ pub async fn update_info( .raw_update_user_email(id, form.email.trim()) .await .map_err(|e| ApiError::Internal(e.to_string()))?; - notice_then_table(&state, lang, "ok", t(lang, "users.profile_updated")).await + notice_then_table(&state, lang, cols, q, page, page_size, "ok", t(lang, "users.profile_updated")).await } #[derive(Debug, Deserialize)] @@ -127,9 +394,14 @@ pub async fn reset_password( admin: AuthedUser, lang: Lang, Path(id): Path, + Query(pg): Query, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_user_columns(&state, admin.user_id).await; + let page_size = load_user_page_size(&state, admin.user_id).await; + let page = pg.page_or_first(); + let q = pg.query(); // 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 @@ -141,10 +413,10 @@ pub async fn reset_password( .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound)?; if target.is_oidc_linked() { - return notice_then_table(&state, lang, "error", t(lang, "users.oidc_password_disabled")).await; + return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.oidc_password_disabled")).await; } if form.password.len() < 4 { - return notice_then_table(&state, lang, "error", t(lang, "users.password_min")).await; + return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.password_min")).await; } let hash = hash_password(form.password) .await @@ -157,6 +429,10 @@ pub async fn reset_password( notice_then_table( &state, lang, + cols, + q, + page, + page_size, if ok { "ok" } else { "error" }, if ok { t(lang, "users.password_updated") } else { t(lang, "users.user_not_found") }, ) @@ -168,10 +444,15 @@ pub async fn toggle_admin( admin: AuthedUser, lang: Lang, Path(id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_user_columns(&state, admin.user_id).await; + let page_size = load_user_page_size(&state, admin.user_id).await; + let page = pg.page_or_first(); + let q = pg.query(); if id == admin.user_id { - return notice_then_table(&state, lang, "error", t(lang, "users.cant_revoke_self")).await; + return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.cant_revoke_self")).await; } let user = state .db @@ -184,7 +465,7 @@ pub async fn toggle_admin( .user_set_admin(id, !user.is_admin) .await .map_err(|e| ApiError::Internal(e.to_string()))?; - Ok(Html(render_table(&state, lang).await?)) + Ok(Html(render_table(&state, lang, cols, q, page, page_size).await?)) } pub async fn toggle_status( @@ -192,10 +473,15 @@ pub async fn toggle_status( admin: AuthedUser, lang: Lang, Path(id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_user_columns(&state, admin.user_id).await; + let page_size = load_user_page_size(&state, admin.user_id).await; + let page = pg.page_or_first(); + let q = pg.query(); if id == admin.user_id { - return notice_then_table(&state, lang, "error", t(lang, "users.cant_disable_self")).await; + return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.cant_disable_self")).await; } let user = state .db @@ -209,7 +495,7 @@ pub async fn toggle_status( .user_set_status(id, new_status) .await .map_err(|e| ApiError::Internal(e.to_string()))?; - Ok(Html(render_table(&state, lang).await?)) + Ok(Html(render_table(&state, lang, cols, q, page, page_size).await?)) } pub async fn delete( @@ -217,10 +503,15 @@ pub async fn delete( admin: AuthedUser, lang: Lang, Path(id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_user_columns(&state, admin.user_id).await; + let page_size = load_user_page_size(&state, admin.user_id).await; + let page = pg.page_or_first(); + let q = pg.query(); if id == admin.user_id { - return notice_then_table(&state, lang, "error", t(lang, "users.cant_delete_self")).await; + return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.cant_delete_self")).await; } let ok = state .db @@ -230,6 +521,10 @@ pub async fn delete( notice_then_table( &state, lang, + cols, + q, + page, + page_size, if ok { "ok" } else { "error" }, if ok { t(lang, "users.user_deleted") } else { t(lang, "common.already_gone") }, ) @@ -243,8 +538,13 @@ pub async fn totp_enroll( admin: AuthedUser, lang: Lang, Path(id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_user_columns(&state, admin.user_id).await; + let page_size = load_user_page_size(&state, admin.user_id).await; + let page = pg.page_or_first(); + let q = pg.query(); let user = state .db .user_find_by_id(id) @@ -283,7 +583,7 @@ pub async fn totp_enroll( otpauth = html_escape(&otpauth), hint = t(lang, "users.otpauth_hint"), ); - html.push_str(&render_table(&state, lang).await?); + html.push_str(&render_table(&state, lang, cols, q, page, page_size).await?); Ok(Html(html)) } @@ -292,8 +592,13 @@ pub async fn totp_unenroll( admin: AuthedUser, lang: Lang, Path(id): Path, + Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; + let cols = load_user_columns(&state, admin.user_id).await; + let page_size = load_user_page_size(&state, admin.user_id).await; + let page = pg.page_or_first(); + let q = pg.query(); let removed = state .db .totp_unenroll(id) @@ -302,6 +607,10 @@ pub async fn totp_unenroll( notice_then_table( &state, lang, + cols, + q, + page, + page_size, if removed { "ok" } else { "error" }, if removed { t(lang, "users.totp_removed") } else { t(lang, "users.no_totp") }, ) @@ -313,60 +622,223 @@ pub async fn totp_unenroll( async fn notice_then_table( state: &Arc, lang: Lang, + cols: UserColumns, + q: &str, + page: i64, + page_size: i64, kind: &str, msg: &str, ) -> Result, ApiError> { let mut html = notice_html(kind, msg); - html.push_str(&render_table(state, lang).await?); + html.push_str(&render_table(state, lang, cols, q, page, page_size).await?); Ok(Html(html)) } -async fn render_full_page(state: &Arc, lang: Lang) -> Result { - let table = render_table(state, lang).await?; +async fn render_full_page( + state: &Arc, + lang: Lang, + user_id: i64, + page: i64, + q: &str, +) -> Result { + let cols = load_user_columns(state, user_id).await; + let page_size = load_user_page_size(state, user_id).await; + let table = render_table(state, lang, cols, q, page, page_size).await?; + let columns_popover = render_columns_popover(lang, cols); Ok(format!( r##"
-
+

{heading}

+
+ + {columns_popover} + +
-
-

{create_heading}

-
- - - - - - -
-
-
{table}
"##, heading = t(lang, "users.heading"), - create_heading = t(lang, "users.create_heading"), - username = t(lang, "users.username"), - display_name = t(lang, "users.display_name"), - email = t(lang, "users.email_optional"), - password = t(lang, "users.password"), - admin = t(lang, "users.admin_label"), - create = t(lang, "common.create"), + add_user = t(lang, "users.add_user"), + columns_popover = columns_popover, + search_placeholder = t(lang, "users.search_placeholder"), + q_value = html_escape(q), table = table, )) } -async fn render_table(state: &Arc, lang: Lang) -> Result { - let (_total, users) = state +fn render_columns_popover(lang: Lang, cols: UserColumns) -> String { + // Each row maps a column id (sent to /admin/pages/users/columns) to + // its current visibility + i18n label. The id is what the JS toggle + // in index.html POSTs; the label is the column header text. + let items: [(&str, bool, &str); 6] = [ + ("display_name", cols.display_name, t(lang, "users.col_display_name")), + ("email", cols.email, t(lang, "users.col_email")), + ("status", cols.status, t(lang, "users.col_status")), + ("admin", cols.admin, t(lang, "users.col_admin")), + ("totp", cols.totp, t(lang, "users.col_totp")), + ("last_seen", cols.last_seen, t(lang, "users.col_last_seen")), + ]; + let mut checkboxes = String::new(); + for (id, visible, label) in items { + let checked = if visible { " checked" } else { "" }; + let _ = write!( + checkboxes, + r##""##, + id = id, + checked = checked, + label = html_escape(label), + ); + } + format!( + r##"
+ + {columns_label} ▾ + +
+ {checkboxes} +
+
"##, + columns_label = t(lang, "users.columns"), + checkboxes = checkboxes, + ) +} + +/// Standalone "new user" page rendered into `#main`. Preserves form +/// values and shows an error notice when re-rendered after a validation +/// failure. +fn render_new_user_page( + lang: Lang, + username: &str, + display_name: &str, + email: &str, + is_admin_checked: bool, + error_msg: Option<&str>, +) -> String { + let error = error_msg.map(|m| notice_html("error", m)).unwrap_or_default(); + let admin_attr = if is_admin_checked { " checked" } else { "" }; + format!( + r##"
+
+

{heading}

+ +
+ + {error} + +
+
+ + + + + +
+ + +
+
+
+
"##, + heading = t(lang, "users.new_user_heading"), + back = t(lang, "common.back"), + error = error, + username_label = t(lang, "users.username"), + display_name_label = t(lang, "users.display_name"), + email_label = t(lang, "users.email_optional"), + password_label = t(lang, "users.password"), + admin_label = t(lang, "users.admin_label"), + cancel = t(lang, "common.cancel"), + create = t(lang, "common.create"), + username_val = html_escape(username), + display_name_val = html_escape(display_name), + email_val = html_escape(email), + admin_attr = admin_attr, + ) +} + +async fn render_table( + state: &Arc, + lang: Lang, + cols: UserColumns, + q: &str, + page: i64, + page_size: i64, +) -> Result { + // Probe the filtered total once so we can clamp the requested page + // before we do the offset query. Saves us from a confusing empty + // page when the caller asks for page 5 of a 1-page dataset (e.g. + // after a delete, or because the search now matches fewer rows). + let (total, _) = state .db - .users_list_all(0, PAGE_SIZE) + .users_list_filtered(q, 0, 0) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let total_pages = if total == 0 { + 1 + } else { + (total + page_size - 1) / page_size + }; + let page = page.clamp(1, total_pages); + let offset = (page - 1) * page_size; + let (_, users) = state + .db + .users_list_filtered(q, offset, page_size) .await .map_err(|e| ApiError::Internal(e.to_string()))?; // One small query per row for the TOTP-enrolled flag — N is small. @@ -393,46 +865,187 @@ async fn render_table(state: &Arc, lang: Lang) -> Result - {c_username} - {c_display_name} - {c_email} - {c_status} - {c_admin} - {c_totp} - {c_last_seen} + {c_username}"##, + c_username = t(lang, "users.col_username"), + ); + if cols.display_name { + let _ = write!(s, r##" + {}"##, t(lang, "users.col_display_name")); + } + if cols.email { + let _ = write!(s, r##" + {}"##, t(lang, "users.col_email")); + } + if cols.status { + let _ = write!(s, r##" + {}"##, t(lang, "users.col_status")); + } + if cols.admin { + let _ = write!(s, r##" + {}"##, t(lang, "users.col_admin")); + } + if cols.totp { + let _ = write!(s, r##" + {}"##, t(lang, "users.col_totp")); + } + if cols.last_seen { + let _ = write!(s, r##" + {}"##, t(lang, "users.col_last_seen")); + } + let _ = write!( + s, + r##" {c_actions} "##, - c_username = t(lang, "users.col_username"), - c_display_name = t(lang, "users.col_display_name"), - c_email = t(lang, "users.col_email"), - c_status = t(lang, "users.col_status"), - c_admin = t(lang, "users.col_admin"), - c_totp = t(lang, "users.col_totp"), - c_last_seen = t(lang, "users.col_last_seen"), c_actions = t(lang, "common.actions"), ); - for u in &users { - render_user_row( - &mut s, - lang, - u, - *totp.get(&u.id).unwrap_or(&false), - last_seen.get(&u.id).map(String::as_str), + if users.is_empty() { + // colspan = visible columns + 1 (actions). + let visible = 1 + + cols.display_name as i64 + + cols.email as i64 + + cols.status as i64 + + cols.admin as i64 + + cols.totp as i64 + + cols.last_seen as i64 + + 1; + let empty_msg = if q.is_empty() { + t(lang, "users.no_users") + } else { + t(lang, "users.no_match") + }; + let _ = write!( + s, + r##"{empty}"##, + visible = visible, + empty = empty_msg, ); + } else { + for u in &users { + render_user_row( + &mut s, + lang, + cols, + q, + page, + u, + *totp.get(&u.id).unwrap_or(&false), + last_seen.get(&u.id).map(String::as_str), + ); + } } s.push_str(" \n"); + s.push_str(&render_pagination_footer(lang, q, page, page_size, total, total_pages)); Ok(s) } +/// Pagination footer shown beneath the table: "Showing 1–100 of 245", +/// the per-page selector, and prev/next buttons. Always rendered (even +/// for empty/single-page lists) so the admin can change the page size. +fn render_pagination_footer( + lang: Lang, + q: &str, + page: i64, + page_size: i64, + total: i64, + total_pages: i64, +) -> String { + let from = if total == 0 { 0 } else { (page - 1) * page_size + 1 }; + let to = ((page * page_size).min(total)).max(0); + let showing = tf3( + lang, + "users.showing_range", + &from.to_string(), + &to.to_string(), + &total.to_string(), + ); + let mut size_options = String::new(); + for &n in PAGE_SIZE_OPTIONS { + let selected = if n == page_size { " selected" } else { "" }; + let _ = write!( + size_options, + r##""##, + n = n, + selected = selected, + ); + } + // Encode the search query into the prev/next URLs so the filter + // sticks across pagination clicks. An empty `q` is fine to leave + // out — the param is `#[serde(default)]` on the receiving end. + let q_param = if q.is_empty() { + String::new() + } else { + format!("&q={}", url_encode(q)) + }; + let prev_disabled = page <= 1; + let next_disabled = page >= total_pages; + let prev_attrs = if prev_disabled { + " disabled".to_string() + } else { + format!( + r##" hx-get="/admin/pages/users/table-fragment?page={p}{q_param}" hx-target="#users-region" hx-swap="innerHTML""##, + p = page - 1, + q_param = q_param, + ) + }; + let next_attrs = if next_disabled { + " disabled".to_string() + } else { + format!( + r##" hx-get="/admin/pages/users/table-fragment?page={p}{q_param}" hx-target="#users-region" hx-swap="innerHTML""##, + p = page + 1, + q_param = q_param, + ) + }; + let btn_base = "px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700 \ + disabled:opacity-40 disabled:hover:bg-slate-800 disabled:cursor-not-allowed"; + format!( + r##"
+
+ {showing} + +
+
+ + {page} / {total_pages} + +
+
"##, + showing = html_escape(&showing), + per_page_label = t(lang, "users.per_page"), + size_options = size_options, + prev = t(lang, "common.prev"), + next = t(lang, "common.next"), + btn = btn_base, + prev_attrs = prev_attrs, + next_attrs = next_attrs, + page = page, + total_pages = total_pages, + ) +} + fn render_user_row( s: &mut String, lang: Lang, + cols: UserColumns, + q: &str, + page: i64, u: &UserRow, has_totp: bool, last_seen: Option<&str>, ) { + let q_param = if q.is_empty() { + String::new() + } else { + format!("&q={}", url_encode(q)) + }; let status_badge = match u.status { 1 => format!( r#"{}"#, @@ -483,11 +1096,13 @@ fn render_user_row( ) } else { format!( - r##"
+ r##"
"##, id = u.id, + page = page, + q_param = q_param, ph = t(lang, "users.new_password"), set = t(lang, "users.password_set"), ) @@ -503,11 +1118,13 @@ fn render_user_row( let totp_button = if has_totp { format!( r##""##, id = u.id, + page = page, + q_param = q_param, confirm = html_escape_attr(&tf1(lang, "users.confirm_disable_totp", &u.username)), label = t(lang, "users.disable_totp"), ) @@ -517,34 +1134,82 @@ fn render_user_row( let _ = write!( s, r##" - {username} - {display_name} - {email} - {status} - {admin} - {totp} - {last_seen_rel} + {username}"##, + username = html_escape(&u.username), + ); + if cols.display_name { + let _ = write!( + s, + r##" + {}"##, + html_escape(&u.display_name), + ); + } + if cols.email { + let _ = write!( + s, + r##" + {}"##, + html_escape(&u.email), + ); + } + if cols.status { + let _ = write!( + s, + r##" + {}"##, + status_badge, + ); + } + if cols.admin { + let _ = write!( + s, + r##" + {}"##, + admin_badge, + ); + } + if cols.totp { + let _ = write!( + s, + r##" + {}"##, + totp_badge, + ); + } + if cols.last_seen { + let _ = write!( + s, + r##" + {rel}"##, + abs = last_seen_abs, + rel = last_seen_rel, + ); + } + let _ = write!( + s, + r##"
···
-
+
{password_form} {totp_button}