diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs index bf54049..7b5b773 100644 --- a/src/api/admin/i18n.rs +++ b/src/api/admin/i18n.rs @@ -1004,6 +1004,76 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Rulează comandă…", "Ejecutar comando…", ), + "devices.add_to_group" => ( + "Add to device group", + "Zur Gerätegruppe hinzufügen", + "Ajouter au groupe d'appareils", + "Adaugă la grupul de dispozitive", + "Añadir al grupo de dispositivos", + ), + "devices.add_to_address_book" => ( + "Add to address book", + "Zum Adressbuch hinzufügen", + "Ajouter au carnet d'adresses", + "Adaugă în agendă", + "Añadir a la libreta de direcciones", + ), + "devices.add_to_strategy" => ( + "Add to strategy", + "Zur Strategie hinzufügen", + "Ajouter à la stratégie", + "Adaugă la strategie", + "Añadir a la estrategia", + ), + "devices.no_groups_available" => ( + "No device groups yet — create one in Groups.", + "Noch keine Gerätegruppen — eine unter Gruppen anlegen.", + "Aucun groupe d'appareils — créez-en un dans Groupes.", + "Niciun grup de dispozitive — creați unul în Grupuri.", + "Aún no hay grupos de dispositivos — cree uno en Grupos.", + ), + "devices.no_address_books_available" => ( + "No shared address books — create one in Address books.", + "Keine geteilten Adressbücher — eines unter Adressbücher anlegen.", + "Aucun carnet d'adresses partagé — créez-en un dans Carnets d'adresses.", + "Nicio agendă partajată — creați una în Agende.", + "No hay libretas de direcciones compartidas — cree una en Libretas de direcciones.", + ), + "devices.no_strategies_available" => ( + "No strategies yet — create one in Strategies.", + "Noch keine Strategien — eine unter Strategien anlegen.", + "Aucune stratégie — créez-en une dans Stratégies.", + "Nicio strategie — creați una în Strategii.", + "Aún no hay estrategias — cree una en Estrategias.", + ), + "devices.added_to_group" => ( + "Added {0} to group {1}.", + "{0} zur Gruppe {1} hinzugefügt.", + "{0} ajouté au groupe {1}.", + "{0} adăugat la grupul {1}.", + "{0} añadido al grupo {1}.", + ), + "devices.added_to_address_book" => ( + "Added {0} to address book {1}.", + "{0} zum Adressbuch {1} hinzugefügt.", + "{0} ajouté au carnet d'adresses {1}.", + "{0} adăugat în agenda {1}.", + "{0} añadido a la libreta de direcciones {1}.", + ), + "devices.already_in_address_book" => ( + "{0} is already in address book {1}.", + "{0} ist bereits im Adressbuch {1}.", + "{0} est déjà dans le carnet d'adresses {1}.", + "{0} este deja în agenda {1}.", + "{0} ya está en la libreta de direcciones {1}.", + ), + "devices.added_to_strategy" => ( + "Assigned {0} to strategy {1}.", + "{0} der Strategie {1} zugewiesen.", + "{0} affecté à la stratégie {1}.", + "{0} atribuit strategiei {1}.", + "{0} asignado a la estrategia {1}.", + ), "exec.heading" => ( "Remote PowerShell", "Remote-PowerShell", diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 49bc051..a4b8027 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -138,6 +138,18 @@ pub fn build(state: Arc) -> Option { "/admin/pages/devices/:peer_id/toggle-managed", post(pages::devices::toggle_managed), ) + .route( + "/admin/pages/devices/:peer_id/add-to-group", + post(pages::devices::add_to_group), + ) + .route( + "/admin/pages/devices/:peer_id/add-to-address-book", + post(pages::devices::add_to_address_book), + ) + .route( + "/admin/pages/devices/:peer_id/add-to-strategy", + post(pages::devices::add_to_strategy), + ) .route( "/admin/pages/devices/:peer_id/exec", get(pages::exec::index).post(pages::exec::dispatch), diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index 5551c62..a1a0842 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -364,6 +364,153 @@ pub async fn toggle_managed( .await } +// ---------- "Add to …" POST handlers (per-device action menu) ---------- + +#[derive(Debug, Deserialize)] +pub struct AddToGroupForm { + pub group_id: i64, +} + +pub async fn add_to_group( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Path(peer_id): Path, + Query(pg): Query, + Form(form): Form, +) -> 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(); + // Look up the group name once so the notice can use it. If the row was + // just deleted by another admin we still proceed with the (now no-op) + // INSERT OR IGNORE and surface a generic notice. + let group_name = state + .db + .device_groups_list_all() + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .into_iter() + .find(|g| g.id == form.group_id) + .map(|g| g.name) + .unwrap_or_else(|| form.group_id.to_string()); + state + .db + .device_group_add_peer(form.group_id, &peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then_table( + &state, + lang, + cols, + q, + page, + page_size, + "ok", + &tf2(lang, "devices.added_to_group", &peer_id, &group_name), + ) + .await +} + +#[derive(Debug, Deserialize)] +pub struct AddToAddressBookForm { + pub guid: String, +} + +pub async fn add_to_address_book( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Path(peer_id): Path, + Query(pg): Query, + Form(form): Form, +) -> 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 book_label = state + .db + .ab_list_all_with_owner() + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .into_iter() + .find(|b| b.guid == form.guid) + .map(|b| { + if b.owner_username.is_empty() { + b.name + } else { + format!("{} ({})", b.name, b.owner_username) + } + }) + .unwrap_or_else(|| form.guid.clone()); + let inserted = state + .db + .ab_peer_add_admin(&form.guid, &peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let (kind, msg) = if inserted { + ("ok", tf2(lang, "devices.added_to_address_book", &peer_id, &book_label)) + } else { + ( + "ok", + tf2(lang, "devices.already_in_address_book", &peer_id, &book_label), + ) + }; + notice_then_table(&state, lang, cols, q, page, page_size, kind, &msg).await +} + +#[derive(Debug, Deserialize)] +pub struct AddToStrategyForm { + pub strategy_id: i64, +} + +pub async fn add_to_strategy( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Path(peer_id): Path, + Query(pg): Query, + Form(form): Form, +) -> 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 strategy_name = state + .db + .strategies_list_all() + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .into_iter() + .find(|s| s.id == form.strategy_id) + .map(|s| s.name) + .unwrap_or_else(|| form.strategy_id.to_string()); + // strategy_assign_peer_replace replaces any prior peer-scoped assignment, + // so this naturally handles "switch this device to a different strategy" + // without needing a separate "unassign first" step in the UI. + state + .db + .strategy_assign_peer_replace(form.strategy_id, &peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then_table( + &state, + lang, + cols, + q, + page, + page_size, + "ok", + &tf2(lang, "devices.added_to_strategy", &peer_id, &strategy_name), + ) + .await +} + // ---------- column visibility / page size POST handlers ---------- #[derive(Debug, Deserialize)] @@ -560,6 +707,29 @@ async fn render_table( .devices_list_filtered(q, offset, page_size) .await .map_err(|e| ApiError::Internal(e.to_string()))?; + // Per-row "Add to …" pickers need the catalog of assignment targets. + // Loaded once per render so the row loop doesn't fan out into 3*N queries. + // Address books are filtered to shared (kind=1) — personal books belong + // to a single user and are synced from the client, so adding from the + // admin dashboard would race their local edits. + let groups = state + .db + .device_groups_list_all() + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let address_books: Vec = state + .db + .ab_list_all_with_owner() + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .into_iter() + .filter(|b| b.kind == 1) + .collect(); + let strategies = state + .db + .strategies_list_all() + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; let now = chrono::Utc::now(); let mut s = String::new(); // `overflow-x-auto` on the inner wrapper gives the wide table a @@ -642,7 +812,19 @@ async fn render_table( } 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); + render_device_row( + &mut s, + lang, + cols, + q, + page, + d, + now, + always_show_pwd, + &groups, + &address_books, + &strategies, + ); } } s.push_str("\n"); @@ -812,6 +994,9 @@ fn render_device_row( d: &DashboardDeviceRow, now: chrono::DateTime, always_show_pwd: bool, + groups: &[crate::database::DeviceGroupRow], + address_books: &[crate::database::AbOverviewRow], + strategies: &[crate::database::StrategyRow], ) { let parsed: serde_json::Value = serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); @@ -918,6 +1103,55 @@ fn render_device_row( format!("&q={}", url_encode(q)) }; + // "Add to …" picker forms — one per assignment target (device group, + // shared address book, strategy). Rendered as inline
+ + {opts} + + +
+"##, + label = html_escape(label), + action = action_url, + field = field_name, + opts = opts, + ) +} + // ---------- detail page ---------- /// HTMX-only endpoint returning just the devices table fragment (no diff --git a/src/database.rs b/src/database.rs index 5f0b2a2..61a2d86 100644 --- a/src/database.rs +++ b/src/database.rs @@ -2018,6 +2018,21 @@ impl Database { Ok(row.try_get("c")?) } + /// Admin-side add of a peer to an address book — inserts only the + /// minimum (`ab_guid`, `peer_id`) and silently no-ops if the row is + /// already there. Returns true when a new row was inserted, false when + /// the peer was already in the book. + pub async fn ab_peer_add_admin(&self, ab_guid: &str, peer_id: &str) -> ResultType { + let res = sqlx::query( + "INSERT OR IGNORE INTO address_book_peers(ab_guid, peer_id) VALUES(?, ?)", + ) + .bind(ab_guid) + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + pub async fn ab_list_tags(&self, ab_guid: &str) -> ResultType> { let rows = sqlx::query( "SELECT name, color FROM address_book_tags WHERE ab_guid = ? ORDER BY name",