From 1534d883009f1d1cecd2e68ed406960402ecba28 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 18 Jul 2025 18:35:04 +0300 Subject: [PATCH] Added web ui --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/client.rs | 16 +++- src/db.rs | 173 ++++++++++++++++++++++++++++++++++-- src/server.rs | 10 ++- src/web.rs | 188 +++++++++++++++++++++++---------------- static/index.html | 3 +- static/script.js | 222 ++++++++++++++++++++++++++++++++++++++++++---- static/style.css | 35 ++++++++ 9 files changed, 543 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0356ab9..be97f9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "khm" -version = "0.5.0" +version = "0.8.0" dependencies = [ "actix-web", "base64 0.21.7", diff --git a/Cargo.toml b/Cargo.toml index 370c0d5..24c9a9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "khm" -version = "0.5.0" +version = "0.8.0" edition = "2021" authors = ["AB "] diff --git a/src/client.rs b/src/client.rs index 235d737..416efd2 100644 --- a/src/client.rs +++ b/src/client.rs @@ -11,6 +11,8 @@ use std::path::Path; struct SshKey { server: String, public_key: String, + #[serde(default)] + deprecated: bool, } fn read_known_hosts(file_path: &str) -> io::Result> { @@ -26,7 +28,11 @@ fn read_known_hosts(file_path: &str) -> io::Result> { if parts.len() >= 2 { let server = parts[0].to_string(); let public_key = parts[1..].join(" "); - keys.push(SshKey { server, public_key }); + keys.push(SshKey { + server, + public_key, + deprecated: false, // Keys from known_hosts are not deprecated + }); } } Err(e) => { @@ -42,10 +48,14 @@ fn write_known_hosts(file_path: &str, keys: &[SshKey]) -> io::Result<()> { let path = Path::new(file_path); let mut file = File::create(&path)?; - for key in keys { + // Filter out deprecated keys - they should not be written to known_hosts + let active_keys: Vec<&SshKey> = keys.iter().filter(|key| !key.deprecated).collect(); + let active_count = active_keys.len(); + + for key in active_keys { writeln!(file, "{} {}", key.server, key.public_key)?; } - info!("Wrote {} keys to known_hosts file", keys.len()); + info!("Wrote {} active keys to known_hosts file (filtered out deprecated keys)", active_count); Ok(()) } diff --git a/src/db.rs b/src/db.rs index a45f654..5e9d4ef 100644 --- a/src/db.rs +++ b/src/db.rs @@ -45,6 +45,7 @@ pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres: host VARCHAR(255) NOT NULL, key TEXT NOT NULL, updated TIMESTAMP WITH TIME ZONE NOT NULL, + deprecated BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT unique_host_key UNIQUE (host, key) )", &[], @@ -79,6 +80,33 @@ pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres: info!("Database schema created successfully"); } else { info!("Database schema already exists"); + + // Check if deprecated column exists, add it if missing (migration) + let column_exists = client + .query( + "SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'keys' + AND column_name = 'deprecated' + )", + &[], + ) + .await? + .get(0) + .map(|row| row.get::<_, bool>(0)) + .unwrap_or(false); + + if !column_exists { + info!("Adding deprecated column to existing keys table..."); + client + .execute( + "ALTER TABLE public.keys ADD COLUMN deprecated BOOLEAN NOT NULL DEFAULT FALSE", + &[], + ) + .await?; + info!("Migration completed: deprecated column added"); + } } Ok(()) @@ -106,9 +134,9 @@ pub async fn batch_insert_keys( key_values.push(&key.public_key); } - // First, check which keys already exist in the database + // First, check which keys already exist in the database (including deprecated status) let mut existing_keys = HashMap::new(); - let mut key_query = String::from("SELECT host, key, key_id FROM public.keys WHERE "); + let mut key_query = String::from("SELECT host, key, key_id, deprecated FROM public.keys WHERE "); for i in 0..keys.len() { if i > 0 { @@ -130,18 +158,27 @@ pub async fn batch_insert_keys( let host: String = row.get(0); let key: String = row.get(1); let key_id: i32 = row.get(2); - existing_keys.insert((host, key), key_id); + let deprecated: bool = row.get(3); + existing_keys.insert((host, key), (key_id, deprecated)); } // Determine which keys need to be inserted and which already exist let mut keys_to_insert = Vec::new(); let mut unchanged_keys = Vec::new(); + let mut ignored_deprecated = 0; for key in keys { let key_tuple = (key.server.clone(), key.public_key.clone()); - if existing_keys.contains_key(&key_tuple) { - unchanged_keys.push((key.clone(), *existing_keys.get(&key_tuple).unwrap())); + if let Some((key_id, is_deprecated)) = existing_keys.get(&key_tuple) { + if *is_deprecated { + // Ignore deprecated keys - don't add them to any flow + ignored_deprecated += 1; + } else { + // Key exists and is not deprecated - add to unchanged + unchanged_keys.push((key.clone(), *key_id)); + } } else { + // Key doesn't exist - add to insert list keys_to_insert.push(key.clone()); } } @@ -200,8 +237,8 @@ pub async fn batch_insert_keys( }; info!( - "Keys stats: received={}, new={}, unchanged={}", - stats.total, stats.inserted, stats.unchanged + "Keys stats: received={}, new={}, unchanged={}, ignored_deprecated={}", + stats.total, stats.inserted, stats.unchanged, ignored_deprecated ); Ok(stats) @@ -293,3 +330,125 @@ pub async fn batch_insert_flow_keys( Ok(affected_usize) } + +// Function to deprecate keys instead of deleting them +pub async fn deprecate_key_by_server( + client: &Client, + server_name: &str, + flow_name: &str, +) -> Result { + // Update keys to deprecated status for the given server + let affected = client + .execute( + "UPDATE public.keys + SET deprecated = TRUE, updated = NOW() + WHERE host = $1 + AND key_id IN ( + SELECT key_id FROM public.flows WHERE name = $2 + )", + &[&server_name, &flow_name], + ) + .await?; + + info!( + "Deprecated {} key(s) for server '{}' in flow '{}'", + affected, server_name, flow_name + ); + + Ok(affected) +} + +// Function to restore deprecated key back to active +pub async fn restore_key_by_server( + client: &Client, + server_name: &str, + flow_name: &str, +) -> Result { + // Update keys to active status for the given server in the flow + let affected = client + .execute( + "UPDATE public.keys + SET deprecated = FALSE, updated = NOW() + WHERE host = $1 + AND deprecated = TRUE + AND key_id IN ( + SELECT key_id FROM public.flows WHERE name = $2 + )", + &[&server_name, &flow_name], + ) + .await?; + + info!( + "Restored {} key(s) for server '{}' in flow '{}'", + affected, server_name, flow_name + ); + + Ok(affected) +} + +// Function to permanently delete keys from database +pub async fn permanently_delete_key_by_server( + client: &Client, + server_name: &str, + flow_name: &str, +) -> Result { + // First, find the key_ids for the given server in the flow + let key_rows = client + .query( + "SELECT k.key_id FROM public.keys k + INNER JOIN public.flows f ON k.key_id = f.key_id + WHERE k.host = $1 AND f.name = $2", + &[&server_name, &flow_name] + ) + .await?; + + if key_rows.is_empty() { + return Ok(0); + } + + let key_ids: Vec = key_rows.iter().map(|row| row.get::<_, i32>(0)).collect(); + + // Delete flow associations first + let mut flow_delete_count = 0; + for key_id in &key_ids { + let deleted = client + .execute( + "DELETE FROM public.flows WHERE name = $1 AND key_id = $2", + &[&flow_name, key_id], + ) + .await?; + flow_delete_count += deleted; + } + + // Check if any of these keys are used in other flows + let mut keys_to_delete = Vec::new(); + for key_id in &key_ids { + let count: i64 = client + .query_one( + "SELECT COUNT(*) FROM public.flows WHERE key_id = $1", + &[key_id], + ) + .await? + .get(0); + + if count == 0 { + keys_to_delete.push(*key_id); + } + } + + // Permanently delete keys that are no longer referenced by any flow + let mut total_deleted = 0; + for key_id in keys_to_delete { + let deleted = client + .execute("DELETE FROM public.keys WHERE key_id = $1", &[&key_id]) + .await?; + total_deleted += deleted; + } + + info!( + "Permanently deleted {} flow associations and {} orphaned keys for server '{}' in flow '{}'", + flow_delete_count, total_deleted, server_name, flow_name + ); + + Ok(std::cmp::max(flow_delete_count, total_deleted)) +} diff --git a/src/server.rs b/src/server.rs index c8fa401..b0ca4b4 100644 --- a/src/server.rs +++ b/src/server.rs @@ -12,6 +12,8 @@ use crate::db; pub struct SshKey { pub server: String, pub public_key: String, + #[serde(default)] + pub deprecated: bool, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -37,7 +39,7 @@ pub fn is_valid_ssh_key(key: &str) -> bool { pub async fn get_keys_from_db(client: &Client) -> Result, tokio_postgres::Error> { let rows = client.query( - "SELECT k.host, k.key, f.name FROM public.keys k INNER JOIN public.flows f ON k.key_id = f.key_id", + "SELECT k.host, k.key, k.deprecated, f.name FROM public.keys k INNER JOIN public.flows f ON k.key_id = f.key_id", &[] ).await?; @@ -46,11 +48,13 @@ pub async fn get_keys_from_db(client: &Client) -> Result, tokio_postgr for row in rows { let host: String = row.get(0); let key: String = row.get(1); - let flow: String = row.get(2); + let deprecated: bool = row.get(2); + let flow: String = row.get(3); let ssh_key = SshKey { server: host, public_key: key, + deprecated, }; if let Some(flow_entry) = flows_map.get_mut(&flow) { @@ -327,6 +331,8 @@ pub async fn run_server(args: crate::Args) -> std::io::Result<()> { // API routes .route("/api/flows", web::get().to(crate::web::get_flows_api)) .route("/{flow_id}/keys/{server}", web::delete().to(crate::web::delete_key_by_server)) + .route("/{flow_id}/keys/{server}/restore", web::post().to(crate::web::restore_key_by_server)) + .route("/{flow_id}/keys/{server}/delete", web::delete().to(crate::web::permanently_delete_key_by_server)) // Original API routes .route("/{flow_id}/keys", web::get().to(get_keys)) .route("/{flow_id}/keys", web::post().to(add_keys)) diff --git a/src/web.rs b/src/web.rs index 956eeaf..34ab83f 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,9 +1,7 @@ use actix_web::{web, HttpResponse, Result}; use log::info; use rust_embed::RustEmbed; -use serde::{Deserialize, Serialize}; use serde_json::json; -use std::collections::HashSet; use tokio_postgres::Client; use crate::server::{get_keys_from_db, Flows}; @@ -12,19 +10,13 @@ use crate::server::{get_keys_from_db, Flows}; #[folder = "static/"] struct StaticAssets; -#[derive(Deserialize)] -struct DeleteKeyPath { - flow_id: String, - server: String, -} - // API endpoint to get list of available flows pub async fn get_flows_api(allowed_flows: web::Data>) -> Result { info!("API request for available flows"); Ok(HttpResponse::Ok().json(&**allowed_flows)) } -// API endpoint to delete a specific key by server name +// API endpoint to deprecate a specific key by server name pub async fn delete_key_by_server( flows: web::Data, path: web::Path<(String, String)>, @@ -33,7 +25,7 @@ pub async fn delete_key_by_server( ) -> Result { let (flow_id_str, server_name) = path.into_inner(); - info!("API request to delete key for server '{}' in flow '{}'", server_name, flow_id_str); + info!("API request to deprecate key for server '{}' in flow '{}'", server_name, flow_id_str); if !allowed_flows.contains(&flow_id_str) { return Ok(HttpResponse::Forbidden().json(json!({ @@ -41,11 +33,119 @@ pub async fn delete_key_by_server( }))); } - // Delete from database - match delete_key_from_db(&db_client, &server_name, &flow_id_str).await { + // Deprecate in database + match crate::db::deprecate_key_by_server(&db_client, &server_name, &flow_id_str).await { + Ok(deprecated_count) => { + if deprecated_count > 0 { + info!("Deprecated {} key(s) for server '{}' in flow '{}'", deprecated_count, server_name, flow_id_str); + + // Refresh the in-memory flows + let updated_flows = match get_keys_from_db(&db_client).await { + Ok(flows) => flows, + Err(e) => { + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": format!("Failed to refresh flows: {}", e) + }))); + } + }; + + let mut flows_guard = flows.lock().unwrap(); + *flows_guard = updated_flows; + + Ok(HttpResponse::Ok().json(json!({ + "message": format!("Successfully deprecated {} key(s) for server '{}'", deprecated_count, server_name), + "deprecated_count": deprecated_count + }))) + } else { + Ok(HttpResponse::NotFound().json(json!({ + "error": format!("No keys found for server '{}'", server_name) + }))) + } + } + Err(e) => { + Ok(HttpResponse::InternalServerError().json(json!({ + "error": format!("Failed to deprecate key: {}", e) + }))) + } + } +} + +// API endpoint to restore a deprecated key +pub async fn restore_key_by_server( + flows: web::Data, + path: web::Path<(String, String)>, + db_client: web::Data>, + allowed_flows: web::Data>, +) -> Result { + let (flow_id_str, server_name) = path.into_inner(); + + info!("API request to restore key for server '{}' in flow '{}'", server_name, flow_id_str); + + if !allowed_flows.contains(&flow_id_str) { + return Ok(HttpResponse::Forbidden().json(json!({ + "error": "Flow ID not allowed" + }))); + } + + // Restore in database + match crate::db::restore_key_by_server(&db_client, &server_name, &flow_id_str).await { + Ok(restored_count) => { + if restored_count > 0 { + info!("Restored {} key(s) for server '{}' in flow '{}'", restored_count, server_name, flow_id_str); + + // Refresh the in-memory flows + let updated_flows = match get_keys_from_db(&db_client).await { + Ok(flows) => flows, + Err(e) => { + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": format!("Failed to refresh flows: {}", e) + }))); + } + }; + + let mut flows_guard = flows.lock().unwrap(); + *flows_guard = updated_flows; + + Ok(HttpResponse::Ok().json(json!({ + "message": format!("Successfully restored {} key(s) for server '{}'", restored_count, server_name), + "restored_count": restored_count + }))) + } else { + Ok(HttpResponse::NotFound().json(json!({ + "error": format!("No deprecated keys found for server '{}'", server_name) + }))) + } + } + Err(e) => { + Ok(HttpResponse::InternalServerError().json(json!({ + "error": format!("Failed to restore key: {}", e) + }))) + } + } +} + +// API endpoint to permanently delete a key +pub async fn permanently_delete_key_by_server( + flows: web::Data, + path: web::Path<(String, String)>, + db_client: web::Data>, + allowed_flows: web::Data>, +) -> Result { + let (flow_id_str, server_name) = path.into_inner(); + + info!("API request to permanently delete key for server '{}' in flow '{}'", server_name, flow_id_str); + + if !allowed_flows.contains(&flow_id_str) { + return Ok(HttpResponse::Forbidden().json(json!({ + "error": "Flow ID not allowed" + }))); + } + + // Permanently delete from database + match crate::db::permanently_delete_key_by_server(&db_client, &server_name, &flow_id_str).await { Ok(deleted_count) => { if deleted_count > 0 { - info!("Deleted {} key(s) for server '{}' in flow '{}'", deleted_count, server_name, flow_id_str); + info!("Permanently deleted {} key(s) for server '{}' in flow '{}'", deleted_count, server_name, flow_id_str); // Refresh the in-memory flows let updated_flows = match get_keys_from_db(&db_client).await { @@ -78,68 +178,6 @@ pub async fn delete_key_by_server( } } -// Helper function to delete a key from database -async fn delete_key_from_db( - client: &Client, - server_name: &str, - flow_name: &str, -) -> Result { - // First, find the key_ids for the given server - let key_rows = client - .query("SELECT key_id FROM public.keys WHERE host = $1", &[&server_name]) - .await?; - - if key_rows.is_empty() { - return Ok(0); - } - - let key_ids: Vec = key_rows.iter().map(|row| row.get::<_, i32>(0)).collect(); - - // Delete flow associations first - let mut flow_delete_count = 0; - for key_id in &key_ids { - let deleted = client - .execute( - "DELETE FROM public.flows WHERE name = $1 AND key_id = $2", - &[&flow_name, key_id], - ) - .await?; - flow_delete_count += deleted; - } - - // Check if any of these keys are used in other flows - let mut keys_to_delete = Vec::new(); - for key_id in &key_ids { - let count: i64 = client - .query_one( - "SELECT COUNT(*) FROM public.flows WHERE key_id = $1", - &[key_id], - ) - .await? - .get(0); - - if count == 0 { - keys_to_delete.push(*key_id); - } - } - - // Delete keys that are no longer referenced by any flow - let mut total_deleted = 0; - for key_id in keys_to_delete { - let deleted = client - .execute("DELETE FROM public.keys WHERE key_id = $1", &[&key_id]) - .await?; - total_deleted += deleted; - } - - info!( - "Deleted {} flow associations and {} orphaned keys for server '{}'", - flow_delete_count, total_deleted, server_name - ); - - Ok(std::cmp::max(flow_delete_count, total_deleted)) -} - // Serve static files from embedded assets pub async fn serve_static_file(path: web::Path) -> Result { let file_path = path.into_inner(); diff --git a/static/index.html b/static/index.html index 4ae9fe5..724a713 100644 --- a/static/index.html +++ b/static/index.html @@ -34,7 +34,8 @@
- + + diff --git a/static/script.js b/static/script.js index a489e56..2108277 100644 --- a/static/script.js +++ b/static/script.js @@ -40,6 +40,11 @@ class SSHKeyManager { this.deleteSelectedKeys(); }); + // Bulk permanent delete button + document.getElementById('bulkPermanentDeleteBtn').addEventListener('click', () => { + this.permanentlyDeleteSelectedKeys(); + }); + // Search input document.getElementById('searchInput').addEventListener('input', (e) => { this.filterKeys(e.target.value); @@ -127,6 +132,13 @@ class SSHKeyManager { option.textContent = flow; select.appendChild(option); }); + + // Auto-select the first flow if available + if (flows.length > 0) { + select.value = flows[0]; + this.currentFlow = flows[0]; + this.loadKeys(); + } } async loadKeys() { @@ -195,16 +207,23 @@ class SSHKeyManager { const keyId = `${key.server}-${key.public_key}`; return ` - + - ${this.escapeHtml(key.server)} + + ${this.escapeHtml(key.server)} + ${key.deprecated ? 'DEPRECATED' : ''} + ${keyType} ${keyPreview} - + ${key.deprecated ? + ` + ` : + `` + } `; @@ -277,10 +296,50 @@ class SSHKeyManager { updateBulkDeleteButton() { const bulkDeleteBtn = document.getElementById('bulkDeleteBtn'); - bulkDeleteBtn.disabled = this.selectedKeys.size === 0; - bulkDeleteBtn.textContent = this.selectedKeys.size > 0 - ? `Delete Selected (${this.selectedKeys.size})` - : 'Delete Selected'; + const bulkPermanentDeleteBtn = document.getElementById('bulkPermanentDeleteBtn'); + + if (this.selectedKeys.size === 0) { + // No keys selected - hide both buttons + bulkDeleteBtn.disabled = true; + bulkDeleteBtn.textContent = 'Deprecate Selected'; + bulkPermanentDeleteBtn.style.display = 'none'; + bulkPermanentDeleteBtn.disabled = true; + return; + } + + // Count selected active and deprecated keys + let activeCount = 0; + let deprecatedCount = 0; + + Array.from(this.selectedKeys).forEach(keyId => { + const key = this.findKeyById(keyId); + if (key) { + if (key.deprecated) { + deprecatedCount++; + } else { + activeCount++; + } + } + }); + + // Show/hide deprecate button + if (activeCount > 0) { + bulkDeleteBtn.disabled = false; + bulkDeleteBtn.textContent = `Deprecate Selected (${activeCount})`; + } else { + bulkDeleteBtn.disabled = true; + bulkDeleteBtn.textContent = 'Deprecate Selected'; + } + + // Show/hide permanent delete button + if (deprecatedCount > 0) { + bulkPermanentDeleteBtn.style.display = 'inline-flex'; + bulkPermanentDeleteBtn.disabled = false; + bulkPermanentDeleteBtn.textContent = `Delete Selected (${deprecatedCount})`; + } else { + bulkPermanentDeleteBtn.style.display = 'none'; + bulkPermanentDeleteBtn.disabled = true; + } } showAddKeyModal() { @@ -346,12 +405,17 @@ class SSHKeyManager { } async deleteKey(keyId) { - if (!confirm('Are you sure you want to delete this SSH key?')) { + const key = this.findKeyById(keyId); + if (!key) return; + + if (key.deprecated) { + this.showToast('This key is already deprecated', 'warning'); return; } - const key = this.findKeyById(keyId); - if (!key) return; + if (!confirm('Are you sure you want to deprecate this SSH key?')) { + return; + } try { this.showLoading(); @@ -361,13 +425,79 @@ class SSHKeyManager { if (!response.ok) { const errorText = await response.text(); - throw new Error(errorText || 'Failed to delete key'); + throw new Error(errorText || 'Failed to deprecate key'); } - this.showToast('SSH key deleted successfully', 'success'); + this.showToast('SSH key deprecated successfully', 'success'); await this.loadKeys(); } catch (error) { - this.showToast('Failed to delete key: ' + error.message, 'error'); + this.showToast('Failed to deprecate key: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + + async restoreKey(keyId) { + const key = this.findKeyById(keyId); + if (!key) return; + + if (!key.deprecated) { + this.showToast('This key is not deprecated', 'warning'); + return; + } + + if (!confirm('Are you sure you want to restore this SSH key from deprecated status?')) { + return; + } + + try { + this.showLoading(); + const response = await fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}/restore`, { + method: 'POST' + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to restore key'); + } + + this.showToast('SSH key restored successfully', 'success'); + await this.loadKeys(); + } catch (error) { + this.showToast('Failed to restore key: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + + async permanentlyDeleteKey(keyId) { + const key = this.findKeyById(keyId); + if (!key) return; + + if (!confirm('⚠️ Are you sure you want to PERMANENTLY DELETE this SSH key?\n\nThis action cannot be undone!')) { + return; + } + + // Double confirmation for permanent deletion + if (!confirm('This will permanently remove the key from the database.\n\nConfirm permanent deletion?')) { + return; + } + + try { + this.showLoading(); + const response = await fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}/delete`, { + method: 'DELETE' + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to permanently delete key'); + } + + this.showToast('SSH key permanently deleted', 'success'); + await this.loadKeys(); + } catch (error) { + this.showToast('Failed to permanently delete key: ' + error.message, 'error'); } finally { this.hideLoading(); } @@ -376,14 +506,25 @@ class SSHKeyManager { async deleteSelectedKeys() { if (this.selectedKeys.size === 0) return; - if (!confirm(`Are you sure you want to delete ${this.selectedKeys.size} selected SSH keys?`)) { + // Filter out already deprecated keys + const activeKeys = Array.from(this.selectedKeys).filter(keyId => { + const key = this.findKeyById(keyId); + return key && !key.deprecated; + }); + + if (activeKeys.length === 0) { + this.showToast('All selected keys are already deprecated', 'warning'); + return; + } + + if (!confirm(`Are you sure you want to deprecate ${activeKeys.length} selected SSH keys?`)) { return; } try { this.showLoading(); - const deletePromises = Array.from(this.selectedKeys).map(keyId => { + const deprecatePromises = activeKeys.map(keyId => { const key = this.findKeyById(keyId); if (!key) return Promise.resolve(); @@ -392,11 +533,56 @@ class SSHKeyManager { }); }); - await Promise.all(deletePromises); - this.showToast(`${this.selectedKeys.size} SSH keys deleted successfully`, 'success'); + await Promise.all(deprecatePromises); + this.showToast(`${activeKeys.length} SSH keys deprecated successfully`, 'success'); await this.loadKeys(); } catch (error) { - this.showToast('Failed to delete selected keys: ' + error.message, 'error'); + this.showToast('Failed to deprecate selected keys: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + + async permanentlyDeleteSelectedKeys() { + if (this.selectedKeys.size === 0) return; + + // Filter only deprecated keys + const deprecatedKeys = Array.from(this.selectedKeys).filter(keyId => { + const key = this.findKeyById(keyId); + return key && key.deprecated; + }); + + if (deprecatedKeys.length === 0) { + this.showToast('No deprecated keys selected', 'warning'); + return; + } + + if (!confirm(`⚠️ Are you sure you want to PERMANENTLY DELETE ${deprecatedKeys.length} deprecated SSH keys?\n\nThis action cannot be undone!`)) { + return; + } + + // Double confirmation for permanent deletion + if (!confirm('This will permanently remove the keys from the database.\n\nConfirm permanent deletion?')) { + return; + } + + try { + this.showLoading(); + + const deletePromises = deprecatedKeys.map(keyId => { + const key = this.findKeyById(keyId); + if (!key) return Promise.resolve(); + + return fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}/delete`, { + method: 'DELETE' + }); + }); + + await Promise.all(deletePromises); + this.showToast(`${deprecatedKeys.length} SSH keys permanently deleted`, 'success'); + await this.loadKeys(); + } catch (error) { + this.showToast('Failed to permanently delete selected keys: ' + error.message, 'error'); } finally { this.hideLoading(); } diff --git a/static/style.css b/static/style.css index 7891dd2..d4ce567 100644 --- a/static/style.css +++ b/static/style.css @@ -119,6 +119,15 @@ header h1 { background-color: var(--danger-hover); } +.btn-success { + background-color: var(--success-color); + color: white; +} + +.btn-success:hover:not(:disabled) { + background-color: #059669; +} + .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; @@ -201,6 +210,21 @@ header h1 { background-color: #f8fafc; } +.keys-table tbody tr.deprecated { + opacity: 0.6; + background-color: #fef2f2; +} + +.keys-table tbody tr.deprecated:hover { + background-color: #fee2e2; +} + +.keys-table tbody tr.deprecated .key-preview, +.keys-table tbody tr.deprecated td:nth-child(2) { + text-decoration: line-through; + color: var(--text-secondary); +} + .key-preview { font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; @@ -226,6 +250,17 @@ header h1 { .key-type.ecdsa { background-color: #e0e7ff; color: #3730a3; } .key-type.dsa { background-color: #fce7f3; color: #9d174d; } +.deprecated-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + background-color: #fecaca; + color: #991b1b; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + margin-left: 0.5rem; +} + .no-keys-message { text-align: center; padding: 3rem;