diff --git a/Cargo.lock b/Cargo.lock index 690264a..1b78d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "khm" -version = "0.4.1" +version = "0.4.2" dependencies = [ "actix-web", "base64 0.21.7", @@ -1079,6 +1079,7 @@ dependencies = [ "serde_json", "tokio", "tokio-postgres", + "tokio-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3c1d367..8264ea9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "khm" -version = "0.4.2" +version = "0.5.0" edition = "2021" authors = ["AB "] @@ -14,6 +14,7 @@ regex = "1.10.5" base64 = "0.21" tokio = { version = "1", features = ["full"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } +tokio-util = { version = "0.7", features = ["codec"] } clap = { version = "4", features = ["derive"] } chrono = "0.4.38" reqwest = { version = "0.12", features = ["json"] } diff --git a/src/client.rs b/src/client.rs index cf2770b..235d737 100644 --- a/src/client.rs +++ b/src/client.rs @@ -162,23 +162,43 @@ async fn get_keys_from_server( pub async fn run_client(args: crate::Args) -> std::io::Result<()> { info!("Client mode: Reading known_hosts file"); - let keys = read_known_hosts(&args.known_hosts).expect("Failed to read known hosts file"); + + let keys = match read_known_hosts(&args.known_hosts) { + Ok(keys) => keys, + Err(e) => { + if e.kind() == io::ErrorKind::NotFound { + info!("known_hosts file not found: {}. Starting with empty key list.", args.known_hosts); + Vec::new() + } else { + error!("Failed to read known_hosts file: {}", e); + return Err(e); + } + } + }; let host = args.host.expect("host is required in client mode"); info!("Client mode: Sending keys to server at {}", host); - send_keys_to_server(&host, keys, &args.basic_auth) - .await - .expect("Failed to send keys to server"); + + if let Err(e) = send_keys_to_server(&host, keys, &args.basic_auth).await { + error!("Failed to send keys to server: {}", e); + return Err(io::Error::new(io::ErrorKind::Other, format!("Network error: {}", e))); + } if args.in_place { info!("Client mode: In-place update is enabled. Fetching keys from server."); - let server_keys = get_keys_from_server(&host, &args.basic_auth) - .await - .expect("Failed to get keys from server"); + let server_keys = match get_keys_from_server(&host, &args.basic_auth).await { + Ok(keys) => keys, + Err(e) => { + error!("Failed to get keys from server: {}", e); + return Err(io::Error::new(io::ErrorKind::Other, format!("Network error: {}", e))); + } + }; info!("Client mode: Writing updated known_hosts file"); - write_known_hosts(&args.known_hosts, &server_keys) - .expect("Failed to write known hosts file"); + if let Err(e) = write_known_hosts(&args.known_hosts, &server_keys) { + error!("Failed to write known_hosts file: {}", e); + return Err(e); + } } info!("Client mode: Finished operations"); diff --git a/src/main.rs b/src/main.rs index dfaadab..cfaaab3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod client; mod db; mod server; +mod web; use clap::Parser; use env_logger; @@ -119,6 +120,19 @@ async fn main() -> std::io::Result<()> { let args = Args::parse(); + // Check if we have the minimum required arguments + if !args.server && args.host.is_none() { + // Neither server mode nor client mode properly configured + eprintln!("Error: You must specify either server mode (--server) or client mode (--host)"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" Server mode: {} --server --db-user admin --db-password pass --flows work,home", env!("CARGO_PKG_NAME")); + eprintln!(" Client mode: {} --host https://khm.example.com/work", env!("CARGO_PKG_NAME")); + eprintln!(); + eprintln!("Use --help for more information."); + std::process::exit(1); + } + if args.server { info!("Running in server mode"); if let Err(e) = server::run_server(args).await { diff --git a/src/server.rs b/src/server.rs index a37d740..c8fa401 100644 --- a/src/server.rs +++ b/src/server.rs @@ -324,8 +324,15 @@ pub async fn run_server(args: crate::Args) -> std::io::Result<()> { .app_data(web::Data::new(flows.clone())) .app_data(web::Data::new(db_client.clone())) .app_data(allowed_flows.clone()) + // 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)) + // Original API routes .route("/{flow_id}/keys", web::get().to(get_keys)) .route("/{flow_id}/keys", web::post().to(add_keys)) + // Web interface routes + .route("/", web::get().to(crate::web::serve_web_interface)) + .route("/static/{filename:.*}", web::get().to(crate::web::serve_static_file)) }) .bind((args.ip.as_str(), args.port))? .run() diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..bf1784f --- /dev/null +++ b/src/web.rs @@ -0,0 +1,214 @@ +use actix_web::{web, HttpResponse, Result}; +use log::info; +use serde_json::json; +use std::collections::HashSet; +use tokio_postgres::Client; + +use crate::server::{get_keys_from_db, Flows}; + +// 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 +pub async fn delete_key_by_server( + flows: web::Data, + flow_id: web::Path, + server: web::Path, + db_client: web::Data>, + allowed_flows: web::Data>, +) -> Result { + let flow_id_str = flow_id.into_inner(); + let server_name = server.into_inner(); + + info!("API request to 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" + }))); + } + + // Delete from database + match delete_key_from_db(&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); + + // 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 deleted {} key(s) for server '{}'", deleted_count, server_name), + "deleted_count": deleted_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 delete key: {}", e) + }))) + } + } +} + +// 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 +pub async fn serve_static_file(path: web::Path) -> Result { + let file_path = path.into_inner(); + + // Get the path to the static directory relative to the executable + let exe_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let exe_dir = exe_path.parent().unwrap_or_else(|| std::path::Path::new(".")); + let static_dir = exe_dir.join("static"); + + // Fallback to current directory if static dir doesn't exist next to executable + let static_dir = if static_dir.exists() { + static_dir + } else { + std::path::PathBuf::from("static") + }; + + let full_path = static_dir.join(&file_path); + + // Security check - ensure the path is within the static directory + if !full_path.starts_with(&static_dir) { + return Ok(HttpResponse::Forbidden().body("Access denied")); + } + + if !full_path.exists() { + return Ok(HttpResponse::NotFound().body(format!("File not found: {}", full_path.display()))); + } + + match tokio::fs::read(&full_path).await { + Ok(contents) => { + let content_type = match full_path.extension().and_then(|s| s.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") => "application/javascript; charset=utf-8", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("svg") => "image/svg+xml", + _ => "application/octet-stream", + }; + + Ok(HttpResponse::Ok() + .content_type(content_type) + .body(contents)) + } + Err(e) => { + info!("Failed to read file {}: {}", full_path.display(), e); + Ok(HttpResponse::InternalServerError().body("Failed to read file")) + } + } +} + +// Serve the main web interface +pub async fn serve_web_interface() -> Result { + // Get the path to the static directory relative to the executable + let exe_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let exe_dir = exe_path.parent().unwrap_or_else(|| std::path::Path::new(".")); + let static_dir = exe_dir.join("static"); + + // Fallback to current directory if static dir doesn't exist next to executable + let static_dir = if static_dir.exists() { + static_dir + } else { + std::path::PathBuf::from("static") + }; + + let index_path = static_dir.join("index.html"); + + if !index_path.exists() { + return Ok(HttpResponse::NotFound().body(format!("Web interface not found: {}", index_path.display()))); + } + + match tokio::fs::read(&index_path).await { + Ok(contents) => { + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(contents)) + } + Err(e) => { + info!("Failed to read index.html: {}", e); + Ok(HttpResponse::InternalServerError().body("Failed to load web interface")) + } + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..4ae9fe5 --- /dev/null +++ b/static/index.html @@ -0,0 +1,134 @@ + + + + + + SSH Key Manager + + + + +
+
+

SSH Key Manager

+
+ + + +
+
+ +
+
+
+ 0 + Total Keys +
+
+ 0 + Unique Servers +
+
+ +
+ + + +
+ +
+ + + + + + + + + + + + + +
+ + ServerKey TypeKey PreviewActions
+ +
+ + +
+
+ + + + + + + + +
+
+
Loading...
+
+ + +
+ + + + diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..a489e56 --- /dev/null +++ b/static/script.js @@ -0,0 +1,511 @@ +class SSHKeyManager { + constructor() { + this.currentFlow = null; + this.keys = []; + this.filteredKeys = []; + this.currentPage = 1; + this.keysPerPage = 20; + this.selectedKeys = new Set(); + + this.initializeEventListeners(); + this.loadFlows(); + } + + initializeEventListeners() { + // Flow selection + document.getElementById('flowSelect').addEventListener('change', (e) => { + this.currentFlow = e.target.value; + if (this.currentFlow) { + this.loadKeys(); + } else { + this.clearTable(); + } + }); + + // Refresh button + document.getElementById('refreshBtn').addEventListener('click', () => { + this.loadFlows(); + if (this.currentFlow) { + this.loadKeys(); + } + }); + + // Add key button + document.getElementById('addKeyBtn').addEventListener('click', () => { + this.showAddKeyModal(); + }); + + // Bulk delete button + document.getElementById('bulkDeleteBtn').addEventListener('click', () => { + this.deleteSelectedKeys(); + }); + + // Search input + document.getElementById('searchInput').addEventListener('input', (e) => { + this.filterKeys(e.target.value); + }); + + // Select all checkbox + document.getElementById('selectAll').addEventListener('change', (e) => { + this.toggleSelectAll(e.target.checked); + }); + + // Pagination + document.getElementById('prevPage').addEventListener('click', () => { + this.changePage(this.currentPage - 1); + }); + + document.getElementById('nextPage').addEventListener('click', () => { + this.changePage(this.currentPage + 1); + }); + + // Modal events + this.initializeModalEvents(); + } + + initializeModalEvents() { + // Add key modal + const addModal = document.getElementById('addKeyModal'); + const addForm = document.getElementById('addKeyForm'); + + addForm.addEventListener('submit', (e) => { + e.preventDefault(); + this.addKey(); + }); + + document.getElementById('cancelAdd').addEventListener('click', () => { + this.hideModal('addKeyModal'); + }); + + // View key modal + document.getElementById('closeView').addEventListener('click', () => { + this.hideModal('viewKeyModal'); + }); + + document.getElementById('copyKey').addEventListener('click', () => { + this.copyKeyToClipboard(); + }); + + // Close modals when clicking on close button or outside + document.querySelectorAll('.modal .close').forEach(closeBtn => { + closeBtn.addEventListener('click', (e) => { + this.hideModal(e.target.closest('.modal').id); + }); + }); + + document.querySelectorAll('.modal').forEach(modal => { + modal.addEventListener('click', (e) => { + if (e.target === modal) { + this.hideModal(modal.id); + } + }); + }); + } + + async loadFlows() { + try { + this.showLoading(); + const response = await fetch('/api/flows'); + if (!response.ok) throw new Error('Failed to load flows'); + + const flows = await response.json(); + this.populateFlowSelector(flows); + } catch (error) { + this.showToast('Failed to load flows: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + + populateFlowSelector(flows) { + const select = document.getElementById('flowSelect'); + select.innerHTML = ''; + + flows.forEach(flow => { + const option = document.createElement('option'); + option.value = flow; + option.textContent = flow; + select.appendChild(option); + }); + } + + async loadKeys() { + if (!this.currentFlow) return; + + try { + this.showLoading(); + const response = await fetch(`/${this.currentFlow}/keys`); + if (!response.ok) throw new Error('Failed to load keys'); + + this.keys = await response.json(); + this.filteredKeys = [...this.keys]; + this.updateStats(); + this.renderTable(); + this.selectedKeys.clear(); + this.updateBulkDeleteButton(); + } catch (error) { + this.showToast('Failed to load keys: ' + error.message, 'error'); + this.clearTable(); + } finally { + this.hideLoading(); + } + } + + filterKeys(searchTerm) { + if (!searchTerm.trim()) { + this.filteredKeys = [...this.keys]; + } else { + const term = searchTerm.toLowerCase(); + this.filteredKeys = this.keys.filter(key => + key.server.toLowerCase().includes(term) || + key.public_key.toLowerCase().includes(term) + ); + } + this.currentPage = 1; + this.renderTable(); + } + + updateStats() { + document.getElementById('totalKeys').textContent = this.keys.length; + + const uniqueServers = new Set(this.keys.map(key => key.server)); + document.getElementById('uniqueServers').textContent = uniqueServers.size; + } + + renderTable() { + const tbody = document.getElementById('keysTableBody'); + const noKeysMessage = document.getElementById('noKeysMessage'); + + if (this.filteredKeys.length === 0) { + tbody.innerHTML = ''; + noKeysMessage.style.display = 'block'; + this.updatePagination(); + return; + } + + noKeysMessage.style.display = 'none'; + + const startIndex = (this.currentPage - 1) * this.keysPerPage; + const endIndex = startIndex + this.keysPerPage; + const pageKeys = this.filteredKeys.slice(startIndex, endIndex); + + tbody.innerHTML = pageKeys.map((key, index) => { + const keyType = this.getKeyType(key.public_key); + const keyPreview = this.getKeyPreview(key.public_key); + const keyId = `${key.server}-${key.public_key}`; + + return ` + + + + + ${this.escapeHtml(key.server)} + ${keyType} + ${keyPreview} + + + + + + `; + }).join(''); + + // Add event listeners for checkboxes + tbody.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + checkbox.addEventListener('change', (e) => { + const keyId = e.target.dataset.keyId; + if (e.target.checked) { + this.selectedKeys.add(keyId); + } else { + this.selectedKeys.delete(keyId); + } + this.updateBulkDeleteButton(); + this.updateSelectAllCheckbox(); + }); + }); + + this.updatePagination(); + } + + updatePagination() { + const totalPages = Math.ceil(this.filteredKeys.length / this.keysPerPage); + + document.getElementById('pageInfo').textContent = `Page ${this.currentPage} of ${totalPages}`; + document.getElementById('prevPage').disabled = this.currentPage <= 1; + document.getElementById('nextPage').disabled = this.currentPage >= totalPages; + } + + changePage(newPage) { + const totalPages = Math.ceil(this.filteredKeys.length / this.keysPerPage); + if (newPage >= 1 && newPage <= totalPages) { + this.currentPage = newPage; + this.renderTable(); + } + } + + toggleSelectAll(checked) { + this.selectedKeys.clear(); + + if (checked) { + this.filteredKeys.forEach(key => { + const keyId = `${key.server}-${key.public_key}`; + this.selectedKeys.add(keyId); + }); + } + + this.renderTable(); + this.updateBulkDeleteButton(); + } + + updateSelectAllCheckbox() { + const selectAllCheckbox = document.getElementById('selectAll'); + const visibleKeys = this.filteredKeys.length; + const selectedVisibleKeys = this.filteredKeys.filter(key => + this.selectedKeys.has(`${key.server}-${key.public_key}`) + ).length; + + if (selectedVisibleKeys === 0) { + selectAllCheckbox.indeterminate = false; + selectAllCheckbox.checked = false; + } else if (selectedVisibleKeys === visibleKeys) { + selectAllCheckbox.indeterminate = false; + selectAllCheckbox.checked = true; + } else { + selectAllCheckbox.indeterminate = true; + } + } + + 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'; + } + + showAddKeyModal() { + if (!this.currentFlow) { + this.showToast('Please select a flow first', 'warning'); + return; + } + + document.getElementById('serverInput').value = ''; + document.getElementById('keyInput').value = ''; + this.showModal('addKeyModal'); + } + + async addKey() { + const server = document.getElementById('serverInput').value.trim(); + const publicKey = document.getElementById('keyInput').value.trim(); + + if (!server || !publicKey) { + this.showToast('Please fill in all fields', 'warning'); + return; + } + + if (!this.validateSSHKey(publicKey)) { + this.showToast('Invalid SSH key format', 'error'); + return; + } + + try { + this.showLoading(); + const response = await fetch(`/${this.currentFlow}/keys`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([{ + server: server, + public_key: publicKey + }]) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to add key'); + } + + this.hideModal('addKeyModal'); + this.showToast('SSH key added successfully', 'success'); + await this.loadKeys(); + } catch (error) { + this.showToast('Failed to add key: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + + viewKey(keyId) { + const key = this.findKeyById(keyId); + if (!key) return; + + document.getElementById('viewServer').textContent = key.server; + document.getElementById('viewKey').value = key.public_key; + this.showModal('viewKeyModal'); + } + + async deleteKey(keyId) { + if (!confirm('Are you sure you want to delete this SSH key?')) { + return; + } + + const key = this.findKeyById(keyId); + if (!key) return; + + try { + this.showLoading(); + const response = await fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to delete key'); + } + + this.showToast('SSH key deleted successfully', 'success'); + await this.loadKeys(); + } catch (error) { + this.showToast('Failed to delete key: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + + async deleteSelectedKeys() { + if (this.selectedKeys.size === 0) return; + + if (!confirm(`Are you sure you want to delete ${this.selectedKeys.size} selected SSH keys?`)) { + return; + } + + try { + this.showLoading(); + + const deletePromises = Array.from(this.selectedKeys).map(keyId => { + const key = this.findKeyById(keyId); + if (!key) return Promise.resolve(); + + return fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}`, { + method: 'DELETE' + }); + }); + + await Promise.all(deletePromises); + this.showToast(`${this.selectedKeys.size} SSH keys deleted successfully`, 'success'); + await this.loadKeys(); + } catch (error) { + this.showToast('Failed to delete selected keys: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + + findKeyById(keyId) { + return this.keys.find(key => `${key.server}-${key.public_key}` === keyId); + } + + validateSSHKey(key) { + const sshKeyRegex = /^(ssh-rsa|ssh-dss|ecdsa-sha2-nistp(256|384|521)|ssh-ed25519)\s+[A-Za-z0-9+/]+=*(\s+.*)?$/; + return sshKeyRegex.test(key.trim()); + } + + getKeyType(publicKey) { + if (publicKey.startsWith('ssh-rsa')) return 'RSA'; + if (publicKey.startsWith('ssh-ed25519')) return 'ED25519'; + if (publicKey.startsWith('ecdsa-sha2-nistp')) return 'ECDSA'; + if (publicKey.startsWith('ssh-dss')) return 'DSA'; + return 'Unknown'; + } + + getKeyPreview(publicKey) { + const parts = publicKey.split(' '); + if (parts.length >= 2) { + const keyPart = parts[1]; + if (keyPart.length > 20) { + return keyPart.substring(0, 20) + '...'; + } + return keyPart; + } + return publicKey.substring(0, 20) + '...'; + } + + escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, (m) => map[m]); + } + + copyKeyToClipboard() { + const keyTextarea = document.getElementById('viewKey'); + keyTextarea.select(); + keyTextarea.setSelectionRange(0, 99999); + + try { + document.execCommand('copy'); + this.showToast('SSH key copied to clipboard', 'success'); + } catch (error) { + this.showToast('Failed to copy to clipboard', 'error'); + } + } + + clearTable() { + document.getElementById('keysTableBody').innerHTML = ''; + document.getElementById('noKeysMessage').style.display = 'block'; + document.getElementById('totalKeys').textContent = '0'; + document.getElementById('uniqueServers').textContent = '0'; + this.selectedKeys.clear(); + this.updateBulkDeleteButton(); + } + + showModal(modalId) { + document.getElementById(modalId).style.display = 'block'; + document.body.style.overflow = 'hidden'; + } + + hideModal(modalId) { + document.getElementById(modalId).style.display = 'none'; + document.body.style.overflow = 'auto'; + } + + showLoading() { + document.getElementById('loadingOverlay').style.display = 'block'; + } + + hideLoading() { + document.getElementById('loadingOverlay').style.display = 'none'; + } + + showToast(message, type = 'info') { + const toastContainer = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + + toastContainer.appendChild(toast); + + // Trigger animation + setTimeout(() => toast.classList.add('show'), 100); + + // Remove toast after 4 seconds + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); + }, 4000); + } +} + +// Initialize the SSH Key Manager when the page loads +document.addEventListener('DOMContentLoaded', () => { + window.sshKeyManager = new SSHKeyManager(); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..7891dd2 --- /dev/null +++ b/static/style.css @@ -0,0 +1,505 @@ +:root { + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --secondary-color: #64748b; + --danger-color: #dc2626; + --danger-hover: #b91c1c; + --success-color: #16a34a; + --warning-color: #d97706; + --background: #f8fafc; + --surface: #ffffff; + --border: #e2e8f0; + --text-primary: #1e293b; + --text-secondary: #64748b; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --border-radius: 0.5rem; + --font-family: 'Inter', sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + background-color: var(--background); + color: var(--text-primary); + line-height: 1.6; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--border); +} + +header h1 { + font-size: 2.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.flow-selector { + display: flex; + align-items: center; + gap: 1rem; +} + +.flow-selector label { + font-weight: 500; + color: var(--text-secondary); +} + +.flow-selector select { + padding: 0.5rem 1rem; + border: 1px solid var(--border); + border-radius: var(--border-radius); + background: var(--surface); + color: var(--text-primary); + font-size: 1rem; + min-width: 200px; +} + +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: var(--border-radius); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--primary-hover); +} + +.btn-secondary { + background-color: var(--secondary-color); + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background-color: #475569; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background-color: var(--danger-hover); +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +.stats-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-item { + background: var(--surface); + padding: 1.5rem; + border-radius: var(--border-radius); + box-shadow: var(--shadow); + text-align: center; +} + +.stat-value { + display: block; + font-size: 2rem; + font-weight: 600; + color: var(--primary-color); +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; +} + +.actions-panel { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + gap: 1rem; + flex-wrap: wrap; +} + +.search-box input { + padding: 0.5rem 1rem; + border: 1px solid var(--border); + border-radius: var(--border-radius); + background: var(--surface); + color: var(--text-primary); + font-size: 1rem; + width: 300px; +} + +.keys-table-container { + background: var(--surface); + border-radius: var(--border-radius); + box-shadow: var(--shadow); + overflow: hidden; + margin-bottom: 1.5rem; +} + +.keys-table { + width: 100%; + border-collapse: collapse; +} + +.keys-table th, +.keys-table td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.keys-table th { + background-color: #f1f5f9; + font-weight: 600; + color: var(--text-primary); +} + +.keys-table tbody tr:hover { + background-color: #f8fafc; +} + +.key-preview { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; + color: var(--text-secondary); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.key-type { + display: inline-block; + padding: 0.25rem 0.5rem; + background-color: #e0e7ff; + color: #3730a3; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; +} + +.key-type.rsa { background-color: #fef3c7; color: #92400e; } +.key-type.ed25519 { background-color: #dcfce7; color: #166534; } +.key-type.ecdsa { background-color: #e0e7ff; color: #3730a3; } +.key-type.dsa { background-color: #fce7f3; color: #9d174d; } + +.no-keys-message { + text-align: center; + padding: 3rem; + color: var(--text-secondary); + font-size: 1.125rem; +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; +} + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.modal-content { + background-color: var(--surface); + margin: 5% auto; + padding: 0; + border-radius: var(--border-radius); + box-shadow: var(--shadow-lg); + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: hidden; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + font-size: 1.5rem; + font-weight: 600; +} + +.close { + font-size: 1.5rem; + font-weight: bold; + cursor: pointer; + color: var(--text-secondary); + padding: 0.5rem; + border-radius: var(--border-radius); + transition: all 0.2s ease; +} + +.close:hover { + background-color: var(--background); + color: var(--text-primary); +} + +.modal-body { + padding: 1.5rem; + max-height: 60vh; + overflow-y: auto; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-primary); +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--border-radius); + font-size: 1rem; + font-family: var(--font-family); + background: var(--surface); + color: var(--text-primary); + transition: border-color 0.2s ease; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.form-group textarea { + resize: vertical; + min-height: 120px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; +} + +.read-only-field { + background-color: var(--background) !important; + cursor: not-allowed; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 2rem; +} + +.loading-overlay { + display: none; + position: fixed; + z-index: 9999; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(4px); +} + +.loading-spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + border: 4px solid var(--border); + border-top: 4px solid var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.loading-text { + position: absolute; + top: 60%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-secondary); + font-weight: 500; +} + +@keyframes spin { + 0% { transform: translate(-50%, -50%) rotate(0deg); } + 100% { transform: translate(-50%, -50%) rotate(360deg); } +} + +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.toast { + padding: 1rem 1.5rem; + border-radius: var(--border-radius); + color: white; + font-weight: 500; + box-shadow: var(--shadow-lg); + transform: translateX(100%); + transition: transform 0.3s ease; + max-width: 400px; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.toast.show { + transform: translateX(0); +} + +.toast.success { + background-color: var(--success-color); +} + +.toast.error { + background-color: var(--danger-color); +} + +.toast.warning { + background-color: var(--warning-color); +} + +.toast.info { + background-color: var(--primary-color); +} + +@media (max-width: 768px) { + .container { + padding: 1rem; + } + + header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .actions-panel { + flex-direction: column; + align-items: stretch; + } + + .search-box input { + width: 100%; + } + + .keys-table-container { + overflow-x: auto; + } + + .keys-table { + min-width: 600px; + } + + .modal-content { + margin: 10% auto; + width: 95%; + } + + .form-actions { + flex-direction: column; + } + + .stats-panel { + grid-template-columns: 1fr; + } +} + +/* Checkbox styles */ +input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--primary-color); +} + +/* Action buttons in table */ +.table-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Error states */ +.form-group input:invalid, +.form-group textarea:invalid { + border-color: var(--danger-color); +} + +.form-group input:invalid:focus, +.form-group textarea:invalid:focus { + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1); +} + +/* Success states */ +.form-group input:valid, +.form-group textarea:valid { + border-color: var(--success-color); +}