Added web ui

This commit is contained in:
Ultradesu
2025-07-18 18:35:04 +03:00
parent 3fa43d276d
commit 1534d88300
9 changed files with 543 additions and 108 deletions

2
Cargo.lock generated
View File

@@ -1064,7 +1064,7 @@ dependencies = [
[[package]] [[package]]
name = "khm" name = "khm"
version = "0.5.0" version = "0.8.0"
dependencies = [ dependencies = [
"actix-web", "actix-web",
"base64 0.21.7", "base64 0.21.7",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "khm" name = "khm"
version = "0.5.0" version = "0.8.0"
edition = "2021" edition = "2021"
authors = ["AB <ab@hexor.cy>"] authors = ["AB <ab@hexor.cy>"]

View File

@@ -11,6 +11,8 @@ use std::path::Path;
struct SshKey { struct SshKey {
server: String, server: String,
public_key: String, public_key: String,
#[serde(default)]
deprecated: bool,
} }
fn read_known_hosts(file_path: &str) -> io::Result<Vec<SshKey>> { fn read_known_hosts(file_path: &str) -> io::Result<Vec<SshKey>> {
@@ -26,7 +28,11 @@ fn read_known_hosts(file_path: &str) -> io::Result<Vec<SshKey>> {
if parts.len() >= 2 { if parts.len() >= 2 {
let server = parts[0].to_string(); let server = parts[0].to_string();
let public_key = parts[1..].join(" "); 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) => { Err(e) => {
@@ -42,10 +48,14 @@ fn write_known_hosts(file_path: &str, keys: &[SshKey]) -> io::Result<()> {
let path = Path::new(file_path); let path = Path::new(file_path);
let mut file = File::create(&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)?; 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(()) Ok(())
} }

173
src/db.rs
View File

@@ -45,6 +45,7 @@ pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres:
host VARCHAR(255) NOT NULL, host VARCHAR(255) NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
updated TIMESTAMP WITH TIME ZONE NOT NULL, updated TIMESTAMP WITH TIME ZONE NOT NULL,
deprecated BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT unique_host_key UNIQUE (host, key) 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"); info!("Database schema created successfully");
} else { } else {
info!("Database schema already exists"); 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(()) Ok(())
@@ -106,9 +134,9 @@ pub async fn batch_insert_keys(
key_values.push(&key.public_key); 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 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() { for i in 0..keys.len() {
if i > 0 { if i > 0 {
@@ -130,18 +158,27 @@ pub async fn batch_insert_keys(
let host: String = row.get(0); let host: String = row.get(0);
let key: String = row.get(1); let key: String = row.get(1);
let key_id: i32 = row.get(2); 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 // Determine which keys need to be inserted and which already exist
let mut keys_to_insert = Vec::new(); let mut keys_to_insert = Vec::new();
let mut unchanged_keys = Vec::new(); let mut unchanged_keys = Vec::new();
let mut ignored_deprecated = 0;
for key in keys { for key in keys {
let key_tuple = (key.server.clone(), key.public_key.clone()); let key_tuple = (key.server.clone(), key.public_key.clone());
if existing_keys.contains_key(&key_tuple) { if let Some((key_id, is_deprecated)) = existing_keys.get(&key_tuple) {
unchanged_keys.push((key.clone(), *existing_keys.get(&key_tuple).unwrap())); if *is_deprecated {
// Ignore deprecated keys - don't add them to any flow
ignored_deprecated += 1;
} else { } 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()); keys_to_insert.push(key.clone());
} }
} }
@@ -200,8 +237,8 @@ pub async fn batch_insert_keys(
}; };
info!( info!(
"Keys stats: received={}, new={}, unchanged={}", "Keys stats: received={}, new={}, unchanged={}, ignored_deprecated={}",
stats.total, stats.inserted, stats.unchanged stats.total, stats.inserted, stats.unchanged, ignored_deprecated
); );
Ok(stats) Ok(stats)
@@ -293,3 +330,125 @@ pub async fn batch_insert_flow_keys(
Ok(affected_usize) 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<u64, tokio_postgres::Error> {
// 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<u64, tokio_postgres::Error> {
// 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<u64, tokio_postgres::Error> {
// 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<i32> = 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))
}

View File

@@ -12,6 +12,8 @@ use crate::db;
pub struct SshKey { pub struct SshKey {
pub server: String, pub server: String,
pub public_key: String, pub public_key: String,
#[serde(default)]
pub deprecated: bool,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[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<Vec<Flow>, tokio_postgres::Error> { pub async fn get_keys_from_db(client: &Client) -> Result<Vec<Flow>, tokio_postgres::Error> {
let rows = client.query( 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?; ).await?;
@@ -46,11 +48,13 @@ pub async fn get_keys_from_db(client: &Client) -> Result<Vec<Flow>, tokio_postgr
for row in rows { for row in rows {
let host: String = row.get(0); let host: String = row.get(0);
let key: String = row.get(1); 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 { let ssh_key = SshKey {
server: host, server: host,
public_key: key, public_key: key,
deprecated,
}; };
if let Some(flow_entry) = flows_map.get_mut(&flow) { 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 // API routes
.route("/api/flows", web::get().to(crate::web::get_flows_api)) .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}", 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 // Original API routes
.route("/{flow_id}/keys", web::get().to(get_keys)) .route("/{flow_id}/keys", web::get().to(get_keys))
.route("/{flow_id}/keys", web::post().to(add_keys)) .route("/{flow_id}/keys", web::post().to(add_keys))

View File

@@ -1,9 +1,7 @@
use actix_web::{web, HttpResponse, Result}; use actix_web::{web, HttpResponse, Result};
use log::info; use log::info;
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::HashSet;
use tokio_postgres::Client; use tokio_postgres::Client;
use crate::server::{get_keys_from_db, Flows}; use crate::server::{get_keys_from_db, Flows};
@@ -12,19 +10,13 @@ use crate::server::{get_keys_from_db, Flows};
#[folder = "static/"] #[folder = "static/"]
struct StaticAssets; struct StaticAssets;
#[derive(Deserialize)]
struct DeleteKeyPath {
flow_id: String,
server: String,
}
// API endpoint to get list of available flows // API endpoint to get list of available flows
pub async fn get_flows_api(allowed_flows: web::Data<Vec<String>>) -> Result<HttpResponse> { pub async fn get_flows_api(allowed_flows: web::Data<Vec<String>>) -> Result<HttpResponse> {
info!("API request for available flows"); info!("API request for available flows");
Ok(HttpResponse::Ok().json(&**allowed_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( pub async fn delete_key_by_server(
flows: web::Data<Flows>, flows: web::Data<Flows>,
path: web::Path<(String, String)>, path: web::Path<(String, String)>,
@@ -33,7 +25,7 @@ pub async fn delete_key_by_server(
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let (flow_id_str, server_name) = path.into_inner(); 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) { if !allowed_flows.contains(&flow_id_str) {
return Ok(HttpResponse::Forbidden().json(json!({ return Ok(HttpResponse::Forbidden().json(json!({
@@ -41,11 +33,119 @@ pub async fn delete_key_by_server(
}))); })));
} }
// Delete from database // Deprecate in database
match delete_key_from_db(&db_client, &server_name, &flow_id_str).await { 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<Flows>,
path: web::Path<(String, String)>,
db_client: web::Data<std::sync::Arc<Client>>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
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<Flows>,
path: web::Path<(String, String)>,
db_client: web::Data<std::sync::Arc<Client>>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
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) => { Ok(deleted_count) => {
if deleted_count > 0 { 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 // Refresh the in-memory flows
let updated_flows = match get_keys_from_db(&db_client).await { 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<u64, tokio_postgres::Error> {
// 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<i32> = 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 // Serve static files from embedded assets
pub async fn serve_static_file(path: web::Path<String>) -> Result<HttpResponse> { pub async fn serve_static_file(path: web::Path<String>) -> Result<HttpResponse> {
let file_path = path.into_inner(); let file_path = path.into_inner();

View File

@@ -34,7 +34,8 @@
<div class="actions-panel"> <div class="actions-panel">
<button id="addKeyBtn" class="btn btn-primary">Add SSH Key</button> <button id="addKeyBtn" class="btn btn-primary">Add SSH Key</button>
<button id="bulkDeleteBtn" class="btn btn-danger" disabled>Delete Selected</button> <button id="bulkDeleteBtn" class="btn btn-danger" disabled>Deprecate Selected</button>
<button id="bulkPermanentDeleteBtn" class="btn btn-danger" disabled style="display: none;">Delete Selected</button>
<div class="search-box"> <div class="search-box">
<input type="text" id="searchInput" placeholder="Search servers or keys..."> <input type="text" id="searchInput" placeholder="Search servers or keys...">
</div> </div>

View File

@@ -40,6 +40,11 @@ class SSHKeyManager {
this.deleteSelectedKeys(); this.deleteSelectedKeys();
}); });
// Bulk permanent delete button
document.getElementById('bulkPermanentDeleteBtn').addEventListener('click', () => {
this.permanentlyDeleteSelectedKeys();
});
// Search input // Search input
document.getElementById('searchInput').addEventListener('input', (e) => { document.getElementById('searchInput').addEventListener('input', (e) => {
this.filterKeys(e.target.value); this.filterKeys(e.target.value);
@@ -127,6 +132,13 @@ class SSHKeyManager {
option.textContent = flow; option.textContent = flow;
select.appendChild(option); 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() { async loadKeys() {
@@ -195,16 +207,23 @@ class SSHKeyManager {
const keyId = `${key.server}-${key.public_key}`; const keyId = `${key.server}-${key.public_key}`;
return ` return `
<tr> <tr${key.deprecated ? ' class="deprecated"' : ''}>
<td> <td>
<input type="checkbox" data-key-id="${keyId}" ${this.selectedKeys.has(keyId) ? 'checked' : ''}> <input type="checkbox" data-key-id="${keyId}" ${this.selectedKeys.has(keyId) ? 'checked' : ''}>
</td> </td>
<td>${this.escapeHtml(key.server)}</td> <td>
${this.escapeHtml(key.server)}
${key.deprecated ? '<span class="deprecated-badge">DEPRECATED</span>' : ''}
</td>
<td><span class="key-type ${keyType.toLowerCase()}">${keyType}</span></td> <td><span class="key-type ${keyType.toLowerCase()}">${keyType}</span></td>
<td><span class="key-preview">${keyPreview}</span></td> <td><span class="key-preview">${keyPreview}</span></td>
<td class="table-actions"> <td class="table-actions">
<button class="btn btn-sm btn-secondary" onclick="sshKeyManager.viewKey('${keyId}')">View</button> <button class="btn btn-sm btn-secondary" onclick="sshKeyManager.viewKey('${keyId}')">View</button>
<button class="btn btn-sm btn-danger" onclick="sshKeyManager.deleteKey('${keyId}')">Delete</button> ${key.deprecated ?
`<button class="btn btn-sm btn-success" onclick="sshKeyManager.restoreKey('${keyId}')">Restore</button>
<button class="btn btn-sm btn-danger" onclick="sshKeyManager.permanentlyDeleteKey('${keyId}')">Delete</button>` :
`<button class="btn btn-sm btn-danger" onclick="sshKeyManager.deleteKey('${keyId}')">Deprecate</button>`
}
</td> </td>
</tr> </tr>
`; `;
@@ -277,10 +296,50 @@ class SSHKeyManager {
updateBulkDeleteButton() { updateBulkDeleteButton() {
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn'); const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
bulkDeleteBtn.disabled = this.selectedKeys.size === 0; const bulkPermanentDeleteBtn = document.getElementById('bulkPermanentDeleteBtn');
bulkDeleteBtn.textContent = this.selectedKeys.size > 0
? `Delete Selected (${this.selectedKeys.size})` if (this.selectedKeys.size === 0) {
: 'Delete Selected'; // 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() { showAddKeyModal() {
@@ -346,12 +405,17 @@ class SSHKeyManager {
} }
async deleteKey(keyId) { 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; return;
} }
const key = this.findKeyById(keyId); if (!confirm('Are you sure you want to deprecate this SSH key?')) {
if (!key) return; return;
}
try { try {
this.showLoading(); this.showLoading();
@@ -361,13 +425,79 @@ class SSHKeyManager {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); 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(); await this.loadKeys();
} catch (error) { } 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 { } finally {
this.hideLoading(); this.hideLoading();
} }
@@ -376,14 +506,25 @@ class SSHKeyManager {
async deleteSelectedKeys() { async deleteSelectedKeys() {
if (this.selectedKeys.size === 0) return; 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; return;
} }
try { try {
this.showLoading(); this.showLoading();
const deletePromises = Array.from(this.selectedKeys).map(keyId => { const deprecatePromises = activeKeys.map(keyId => {
const key = this.findKeyById(keyId); const key = this.findKeyById(keyId);
if (!key) return Promise.resolve(); if (!key) return Promise.resolve();
@@ -392,11 +533,56 @@ class SSHKeyManager {
}); });
}); });
await Promise.all(deletePromises); await Promise.all(deprecatePromises);
this.showToast(`${this.selectedKeys.size} SSH keys deleted successfully`, 'success'); this.showToast(`${activeKeys.length} SSH keys deprecated successfully`, 'success');
await this.loadKeys(); await this.loadKeys();
} catch (error) { } 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 { } finally {
this.hideLoading(); this.hideLoading();
} }

View File

@@ -119,6 +119,15 @@ header h1 {
background-color: var(--danger-hover); background-color: var(--danger-hover);
} }
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover:not(:disabled) {
background-color: #059669;
}
.btn-sm { .btn-sm {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
@@ -201,6 +210,21 @@ header h1 {
background-color: #f8fafc; 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 { .key-preview {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem; font-size: 0.875rem;
@@ -226,6 +250,17 @@ header h1 {
.key-type.ecdsa { background-color: #e0e7ff; color: #3730a3; } .key-type.ecdsa { background-color: #e0e7ff; color: #3730a3; }
.key-type.dsa { background-color: #fce7f3; color: #9d174d; } .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 { .no-keys-message {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;