diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 0c3e464..5a48bd7 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -42,17 +42,25 @@ jobs: - name: Test CLI create command run: | - ./target/release/passlock create testpassword123 + ./target/release/passlock vault create myVault mySecurePass123 - if [ ! -f ~/.passlock.vault ]; then - echo "Vault file not created" + # Check for vault in the new location + if [ ! -f ~/.passlock/vaults/myVault.vault ]; then + echo "Vault file not created at ~/.passlock/vaults/myVault.vault" exit 1 fi + + # Check config file exists + if [ ! -f ~/.passlock/config.json ]; then + echo "Config file not created at ~/.passlock/config.json" + exit 1 + fi + echo "Vault created successfully" - name: Test CLI unlock command run: | - ./target/release/passlock unlock testpassword123 + ./target/release/passlock unlock mySecurePass123 if [ ! -f ~/.passlock.temp ]; then echo "Temp file not created" @@ -62,9 +70,9 @@ jobs: - name: Test full encryption/decryption cycle run: | - rm -f ~/.passlock.vault ~/.passlock.temp + rm -f ~/.passlock/vaults/myVault.vault ~/.passlock.temp ~/.passlock/config.json - ./target/release/passlock create mySecurePass123 + ./target/release/passlock vault create myVault mySecurePass123 ./target/release/passlock unlock mySecurePass123 echo '{"e":[],"s":"'$(hexdump -n 16 -e '16/1 "%02x"' /dev/urandom)'"}' > ~/.passlock.temp @@ -205,7 +213,14 @@ jobs: cargo build --release cargo test - ./target/release/passlock create password123 + ./target/release/passlock vault create myVault password123 + + # Check for vault in the new location + if [ ! -f ~/.passlock/vaults/myVault.vault ]; then + echo "Vault file not created at ~/.passlock/vaults/myVault.vault" + exit 1 + fi + ./target/release/passlock unlock password123 echo "Cross-platform build successful on ${{ matrix.os }}" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c1801bf..319219e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "arboard" version = "3.6.1" @@ -61,6 +70,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytemuck" version = "1.25.0" @@ -104,6 +119,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -126,6 +154,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -380,6 +414,30 @@ dependencies = [ "digest", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "image" version = "0.25.9" @@ -418,6 +476,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.182" @@ -584,6 +652,12 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "option-ext" version = "0.2.0" @@ -620,6 +694,7 @@ dependencies = [ "arboard", "base32", "cc", + "chrono", "crossterm", "dirs", "hex", @@ -1061,6 +1136,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + [[package]] name = "weezl" version = "0.1.12" @@ -1089,12 +1209,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 2dd0f09..63deb78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ hex = "0.4" arboard = "3.3" totp-lite = "2.0" base32 = "0.4" +chrono = "0.4" [build-dependencies] cc = "1.0" diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 0000000..b881e15 --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,477 @@ +use crate::models::Entry; +use crate::storage; +use chrono::{DateTime, Local}; +use std::fmt::Write; +use std::fs; +use std::path::PathBuf; + +type BackupInfo = Vec<(String, u64, String)>; +type Result = std::result::Result>; + +/// Gets the backup directory path +pub fn gback_dir() -> PathBuf { + crate::config::get_passlock_dir().join("backups") +} + +/// Gets the vault specific backup directory +pub fn gvback_dir(vault_name: &str) -> PathBuf { + gback_dir().join(vault_name) +} + +/// Initialize backup directory structure +pub fn init_bsys() -> Result<()> { + let backup_dir = gback_dir(); + + if !backup_dir.exists() { + fs::create_dir_all(&backup_dir)?; + println!("[✔] Created backup directory: {}", backup_dir.display()); + } + + Ok(()) +} + +/// Create a timestamped backup of the vault +/// +/// Only creates a new backup if the vault has actually changed +/// since the last backup +/// +/// Returns: (`back_fn`, `was_actually_created`) +pub fn create_backup(vault_name: &str, max_backups: usize, force: bool) -> Result<(String, bool)> { + let vault_path = crate::config::get_vault_path(vault_name); + + if !vault_path.exists() { + return Err(format!("Vault '{vault_name}' not found").into()); + } + + if !force { + let backup_dir = gvback_dir(vault_name); + if backup_dir.exists() { + let backups = ls_backs(vault_name)?; + if !backups.is_empty() { + let last_back = &backups[0]; + let lb_path = backup_dir.join(&last_back.0); + + let vault_meta = fs::metadata(&vault_path)?; + let backup_meta = fs::metadata(&lb_path)?; + + if vault_meta.len() == backup_meta.len() { + println!("[✔] Backup skipped (no changes detected)"); + return Ok((last_back.0.clone(), false)); + } + } + } + } + + let timestamp = Local::now().format("%Y-%m-%d_%H-%M-%S"); + let back_fn = format!("backup_{timestamp}.vault"); + + let backup_dir = gvback_dir(vault_name); + fs::create_dir_all(&backup_dir)?; + + let backup_path = backup_dir.join(&back_fn); + + fs::copy(&vault_path, &backup_path)?; + + println!("[✔] Backup created: {back_fn}"); + + clean_obs(vault_name, max_backups)?; + + Ok((back_fn, true)) +} + +/// List all backups for a vault +pub fn ls_backs(vault_name: &str) -> Result { + let backup_dir = gvback_dir(vault_name); + + if !backup_dir.exists() { + return Ok(Vec::new()); + } + + let mut backups = Vec::new(); + + for entry in fs::read_dir(backup_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("vault") { + let metadata = fs::metadata(&path)?; + let filename = path.file_name().unwrap().to_string_lossy().to_string(); + let size = metadata.len(); + + let created = metadata.created()?; + let datetime: DateTime = created.into(); + let created_str = datetime.format("%Y-%m-%d %H:%M:%S").to_string(); + + backups.push((filename, size, created_str)); + } + } + + backups.sort_by(|a, b| b.0.cmp(&a.0)); + + Ok(backups) +} + +/// Restore vault from a back +pub fn restore_backup(vault_name: &str, back_fn: &str, password: &str) -> Result<()> { + let backup_dir = gvback_dir(vault_name); + let backup_path = backup_dir.join(back_fn); + + if !backup_path.exists() { + return Err(format!("Backup file not found: {back_fn}").into()); + } + + println!("[...] Verifying backup with password..."); + + let vault_path = crate::config::get_vault_path(vault_name); + let temp_path = crate::config::get_passlock_dir().join(format!("{vault_name}.temp")); + + fs::copy(&backup_path, &temp_path)?; + + if storage::ld_vt(password).is_ok() { + fs::remove_file(&temp_path)?; + + if vault_path.exists() { + let safety_backup = + crate::config::get_passlock_dir().join(format!("{vault_name}.before_restore")); + fs::copy(&vault_path, &safety_backup)?; + println!("[✔] Current vault backed up to: {vault_name}.before_restore"); + } + + fs::copy(&backup_path, &vault_path)?; + println!("[✔] Vault '{vault_name}' restored from: {back_fn}"); + + Ok(()) + } else { + fs::remove_file(&temp_path)?; + Err("Incorrect password for this backup".into()) + } +} + +/// Clean up old backups, keeping recent N backs +fn clean_obs(vault_name: &str, max_backups: usize) -> Result<()> { + let backups = ls_backs(vault_name)?; + + if backups.len() > max_backups { + let backup_dir = gvback_dir(vault_name); + let to_delete = &backups[max_backups..]; + + for (filename, _, _) in to_delete { + let backup_path = backup_dir.join(filename); + fs::remove_file(&backup_path)?; + println!("[✔] Removed old backup: {filename}"); + } + } + + Ok(()) +} + +/// Export vault to external location +pub fn export_vault(vault_name: &str, password: &str, output_path: &str) -> Result<()> { + let _vault = storage::ld_vt(password)?; + + let vault_path = crate::config::get_vault_path(vault_name); + + if !vault_path.exists() { + return Err(format!("Vault '{vault_name}' not found").into()); + } + + fs::copy(&vault_path, output_path)?; + + println!("[✔] Vault '{vault_name}' exported to: {output_path}"); + println!("[✔] Format: Encrypted PassLock vault"); + println!("[!] Keep this file secure."); + + Ok(()) +} + +/// Import vault from external location +pub fn import_vault(vault_name: &str, password: &str, input_path: &str) -> Result<()> { + let import_path = PathBuf::from(input_path); + + if !import_path.exists() { + return Err(format!("Import file not found: {input_path}").into()); + } + + let vault_path = crate::config::get_vault_path(vault_name); + let temp_path = crate::config::get_passlock_dir().join(format!("{vault_name}.import_test")); + + fs::copy(&import_path, &temp_path)?; + + println!("[...] Verifying import file..."); + + if storage::ld_vt(password).is_ok() { + fs::remove_file(&temp_path)?; + + if vault_path.exists() { + let safety_backup = + crate::config::get_passlock_dir().join(format!("{vault_name}.before_import")); + fs::copy(&vault_path, &safety_backup)?; + println!("[✔] Current vault backed up: {vault_name}.before_import"); + } + + fs::copy(&import_path, &vault_path)?; + println!("[✔] Vault '{vault_name}' imported from: {input_path}"); + + Ok(()) + } else { + fs::remove_file(&temp_path)?; + Err("Incorrect password for this vault file".into()) + } +} + +/// Export to CSV +pub fn export_csv(vault_name: &str, password: &str, output_path: &str) -> Result<()> { + let vault = storage::ld_vt(password)?; + + let mut csv_content = String::from("name,username,password,url,notes,tags,2fa_secret\n"); + + for entry in &vault.e { + let name = esc_csv(&entry.n); + let username = esc_csv(&entry.u); + let password_val = esc_csv(&entry.p); + let url = entry.url.as_ref().map_or(String::new(), |u| esc_csv(u)); + let notes = entry.nt.as_ref().map_or(String::new(), |n| esc_csv(n)); + let tags = esc_csv(&entry.tags.join(";")); + let totp = entry + .totp_secret + .as_ref() + .map_or(String::new(), |t| esc_csv(t)); + + writeln!( + csv_content, + "{name},{username},{password_val},{url},{totp},{tags},{notes}" + )?; + } + + fs::write(output_path, csv_content)?; + + println!("[✔] Vault '{vault_name}' exported to CSV: {output_path}"); + println!("[!] WARNING: This is Plaintext, delete after use."); + println!("[✔] {} entries exported", vault.e.len()); + + Ok(()) +} + +/// Export to JSON +pub fn export_json(vault_name: &str, password: &str, output_path: &str) -> Result<()> { + let vault = storage::ld_vt(password)?; + + let mut entries = Vec::new(); + for entry in &vault.e { + let mut json_entry = serde_json::json!({ + "name": entry.n, + "username": entry.u, + "password": entry.p, + "tags": entry.tags, + }); + + if let Some(ref url) = entry.url { + json_entry["url"] = serde_json::json!(url); + } + if let Some(ref notes) = entry.nt { + json_entry["notes"] = serde_json::json!(notes); + } + if let Some(ref totp) = entry.totp_secret { + json_entry["totp_secret"] = serde_json::json!(totp); + } + + entries.push(json_entry); + } + + let json_output = serde_json::json!({ + "passlock_version": env!("CARGO_PKG_VERSION"), + "vault_name": vault_name, + "exported_at": Local::now().to_rfc3339(), + "entries": entries, + }); + + let json_string = serde_json::to_string_pretty(&json_output)?; + fs::write(output_path, json_string)?; + + println!("[✔] Vault '{vault_name}' exported to JSON: {output_path}"); + println!("[!] WARNING: This is Plaintext, delete after use."); + println!("[✔] {} entries exported", vault.e.len()); + + Ok(()) +} + +/// Import from CSV +pub fn import_csv(vault_name: &str, password: &str, input_path: &str) -> Result<()> { + use std::io::{BufRead, BufReader}; + + let mut vault = storage::ld_vt(password)?; + + let file = fs::File::open(input_path)?; + let reader = BufReader::new(file); + let mut lines = reader.lines(); + + if let Some(Ok(header)) = lines.next() { + if !header.contains("name") || !header.contains("password") { + return Err("Invalid CSV format".into()); + } + } else { + return Err("Empty CSV file".into()); + } + + let mut imported_count = 0; + let mut skipped_count = 0; + + for line_result in lines { + let line = line_result?; + if line.trim().is_empty() { + continue; + } + + let fields = parse_csv_line(&line); + + if fields.len() < 3 { + skipped_count += 1; + continue; + } + + let entry = Entry { + id: crate::generate_uuid(), + n: fields[0].clone(), + u: fields.get(1).cloned().unwrap_or_default(), + p: fields.get(2).cloned().unwrap_or_default(), + url: fields + .get(3) + .and_then(|s| if s.is_empty() { None } else { Some(s.clone()) }), + nt: fields + .get(4) + .and_then(|s| if s.is_empty() { None } else { Some(s.clone()) }), + tags: fields.get(5).map_or(Vec::new(), |s| { + s.split(';') + .filter(|t| !t.is_empty()) + .map(String::from) + .collect() + }), + totp_secret: fields + .get(6) + .and_then(|s| if s.is_empty() { None } else { Some(s.clone()) }), + t: crate::get_timestamp(), + last_modified: crate::get_timestamp(), + history: Vec::new(), + }; + + vault.e.push(entry); + imported_count += 1; + } + + storage::svv(&vault, password)?; + + println!("[✔] Import completed to vault '{vault_name}'"); + println!("[✔] Imported: {imported_count} entries"); + if skipped_count > 0 { + println!("[!] Skipped: {skipped_count} invalid entries"); + } + + let config = crate::config::load_config()?; + if config.auto_backup { + create_backup(vault_name, config.max_backups, false)?; + } + + Ok(()) +} + +/// Import from JSON +pub fn import_json(vault_name: &str, password: &str, input_path: &str) -> Result<()> { + let mut vault = storage::ld_vt(password)?; + + let json_content = fs::read_to_string(input_path)?; + let json: serde_json::Value = serde_json::from_str(&json_content)?; + + let Some(serde_json::Value::Array(entries)) = json.get("entries") else { + return Err("Invalid JSON format".into()); + }; + + let mut imported_count = 0; + + for entry_json in entries { + let entry = Entry { + id: crate::generate_uuid(), + n: entry_json["name"] + .as_str() + .unwrap_or("Untitled") + .to_string(), + u: entry_json["username"].as_str().unwrap_or("").to_string(), + p: entry_json["password"].as_str().unwrap_or("").to_string(), + url: entry_json + .get("url") + .and_then(|v| v.as_str()) + .map(String::from), + nt: entry_json + .get("notes") + .and_then(|v| v.as_str()) + .map(String::from), + totp_secret: entry_json + .get("totp_secret") + .and_then(|v| v.as_str()) + .map(String::from), + tags: match entry_json.get("tags") { + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(), + _ => Vec::new(), + }, + t: crate::get_timestamp(), + last_modified: crate::get_timestamp(), + history: Vec::new(), + }; + + vault.e.push(entry); + imported_count += 1; + } + + storage::svv(&vault, password)?; + + println!("[✔] Import completed to vault '{vault_name}'"); + println!("[✔] Imported: {imported_count} entries"); + + let config = crate::config::load_config()?; + if config.auto_backup { + create_backup(vault_name, config.max_backups, false)?; + } + + Ok(()) +} + +fn esc_csv(field: &str) -> String { + if field.contains(',') || field.contains('"') || field.contains('\n') { + format!("\"{}\"", field.replace('"', "\"\"")) + } else { + field.to_string() + } +} + +fn parse_csv_line(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current_field = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '"' => { + if in_quotes && chars.peek() == Some(&'"') { + current_field.push('"'); + chars.next(); + } else { + in_quotes = !in_quotes; + } + } + ',' if !in_quotes => { + fields.push(current_field.trim().to_string()); + current_field.clear(); + } + _ => { + current_field.push(ch); + } + } + } + + fields.push(current_field.trim().to_string()); + fields +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cbec77f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,163 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Config { + pub active_vault: String, + pub auto_backup: bool, + pub max_backups: usize, + pub clipboard_timeout: u64, + pub refresh_rate: u64, +} + +impl Default for Config { + fn default() -> Self { + Self { + active_vault: String::from("personal"), + auto_backup: true, + max_backups: 10, + clipboard_timeout: 30, + refresh_rate: 100, + } + } +} + +pub fn get_passlock_dir() -> PathBuf { + let home = dirs::home_dir().expect("Could not find home directory"); + home.join(".passlock") +} + +pub fn get_vaults_dir() -> PathBuf { + get_passlock_dir().join("vaults") +} + +pub fn get_config_path() -> PathBuf { + get_passlock_dir().join("config.json") +} + +pub fn get_vault_path(vault_name: &str) -> PathBuf { + get_vaults_dir().join(format!("{vault_name}.vault")) +} + +pub fn init_passlock_dirs() -> Result<(), Box> { + let passlock_dir = get_passlock_dir(); + let vaults_dir = get_vaults_dir(); + + if !passlock_dir.exists() { + fs::create_dir_all(&passlock_dir)?; + println!("[✔] Created PassLock directory: {}", passlock_dir.display()); + } + + if !vaults_dir.exists() { + fs::create_dir_all(&vaults_dir)?; + println!("[✔] Created vaults directory: {}", vaults_dir.display()); + } + + let home = dirs::home_dir().expect("Could not find home directory"); + let old_vault = home.join(".passlock.vault"); + if old_vault.exists() { + let new_vault = get_vault_path("personal"); + if !new_vault.exists() { + fs::copy(&old_vault, &new_vault)?; + println!("[✔] Migrated old vault to: {}", new_vault.display()); + println!("[!] You can delete the old vault: {}", old_vault.display()); + } + } + + Ok(()) +} + +pub fn load_config() -> Result> { + let config_path = get_config_path(); + + if config_path.exists() { + let config_str = fs::read_to_string(config_path)?; + let config: Config = serde_json::from_str(&config_str)?; + Ok(config) + } else { + let config = Config::default(); + save_config(&config)?; + Ok(config) + } +} + +pub fn save_config(config: &Config) -> Result<(), Box> { + let config_path = get_config_path(); + let config_str = serde_json::to_string_pretty(config)?; + fs::write(config_path, config_str)?; + Ok(()) +} + +pub fn list_vaults() -> Result, Box> { + let vaults_dir = get_vaults_dir(); + + if !vaults_dir.exists() { + return Ok(Vec::new()); + } + + let mut vaults = Vec::new(); + + for entry in fs::read_dir(vaults_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("vault") { + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + vaults.push(name.to_string()); + } + } + } + + vaults.sort(); + Ok(vaults) +} + +pub fn vault_exists(vault_name: &str) -> bool { + get_vault_path(vault_name).exists() +} + +pub fn delete_vault(vault_name: &str) -> Result<(), Box> { + let vault_path = get_vault_path(vault_name); + + if !vault_path.exists() { + return Err(format!("Vault '{vault_name}' does not exist").into()); + } + + fs::remove_file(&vault_path)?; + + let backup_dir = crate::backup::gvback_dir(vault_name); + if backup_dir.exists() { + fs::remove_dir_all(&backup_dir)?; + } + + println!("[✔] Deleted vault: {vault_name}"); + println!("[✔] Deleted backups for: {vault_name}"); + + Ok(()) +} + +pub fn rename_vault(old_name: &str, new_name: &str) -> Result<(), Box> { + let old_path = get_vault_path(old_name); + let new_path = get_vault_path(new_name); + + if !old_path.exists() { + return Err(format!("Vault '{old_name}' does not exist").into()); + } + + if new_path.exists() { + return Err(format!("Vault '{new_name}' already exists").into()); + } + + fs::rename(&old_path, &new_path)?; + + let old_backup_dir = crate::backup::gvback_dir(old_name); + let new_backup_dir = crate::backup::gvback_dir(new_name); + if old_backup_dir.exists() { + fs::rename(&old_backup_dir, &new_backup_dir)?; + } + + println!("[✔] Renamed vault: {old_name} → {new_name}"); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index fa64709..52027c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +mod backup; +mod config; mod crypto; mod models; mod storage; @@ -35,21 +37,20 @@ pub fn get_timestamp() -> u64 { .as_secs() } +#[allow(clippy::too_many_lines)] fn main() -> Result<(), Box> { crypto::init_crypto()?; + config::init_passlock_dirs()?; + backup::init_bsys()?; + + let cfg = config::load_config().unwrap_or_default(); + let active_vault = cfg.active_vault.clone(); + let args: Vec = env::args().collect(); if args.len() > 1 { match args[1].as_str() { - "create" => { - if args.len() < 3 { - eprintln!("Usage: passlock create "); - std::process::exit(1); - } - let password = &args[2]; - create_vault(password)?; - } "unlock" => { if args.len() < 3 { eprintln!("Usage: passlock unlock "); @@ -66,6 +67,107 @@ fn main() -> Result<(), Box> { let password = &args[2]; sync_vault(password)?; } + "backup" => { + handle_backup_cmd(&args[2..])?; + } + "vault" => { + handle_vault_cmd(&args[2..])?; + } + "export" => { + if args.len() < 4 { + eprintln!("Usage: passlock export "); + eprintln!(); + eprintln!("Export encrypted vault for backup/transfer."); + eprintln!("Works with ANY path on ANY device!"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" passlock export myPass123 ~/Documents/backup.vault"); + eprintln!(" passlock export myPass123 /media/usb/backup.vault"); + eprintln!(" passlock export myPass123 ~/Dropbox/backup.vault"); + std::process::exit(1); + } + let password = &args[2]; + let output_file = &args[3]; + backup::export_vault(&active_vault, password, output_file)?; + } + "import" => { + if args.len() < 4 { + eprintln!("Usage: passlock import "); + eprintln!(); + eprintln!("Import encrypted vault from backup."); + eprintln!("Works with ANY path on ANY device!"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" passlock import myPass123 ~/Documents/backup.vault"); + eprintln!(" passlock import myPass123 /media/usb/backup.vault"); + eprintln!(" passlock import myPass123 ~/Dropbox/backup.vault"); + std::process::exit(1); + } + let password = &args[2]; + let input_file = &args[3]; + backup::import_vault(&active_vault, password, input_file)?; + } + "export-csv" => { + if args.len() < 4 { + eprintln!("Usage: passlock export_csv "); + eprintln!(); + eprintln!("WARNING: Creates UNENCRYPTED CSV with plaintext passwords!"); + eprintln!( + "Use for importing to other password managers (LastPass, Bitwarden, etc.)" + ); + eprintln!(); + eprintln!("Example:"); + eprintln!(" passlock export-csv myPass123 ~/passwords.csv"); + std::process::exit(1); + } + let password = &args[2]; + let output_file = &args[3]; + backup::export_csv(&active_vault, password, output_file)?; + } + "export-json" => { + if args.len() < 4 { + eprintln!("Usage: passlock export_json "); + eprintln!(); + eprintln!("WARNING: Creates UNENCRYPTED JSON with plaintext passwords!"); + eprintln!("Use for importing to other password managers."); + eprintln!(); + eprintln!("Example:"); + eprintln!(" passlock export-json myPass123 ~/passwords.json"); + std::process::exit(1); + } + let password = &args[2]; + let output_file = &args[3]; + backup::export_json(&active_vault, password, output_file)?; + } + "import-csv" => { + if args.len() < 4 { + eprintln!("Usage: passlock import-csv "); + eprintln!(); + eprintln!("Import passwords from CSV file (LastPass, Bitwarden, etc.)"); + eprintln!("Expected format: name,username,password,url,notes,tags,2fa_secret"); + eprintln!(); + eprintln!("Example:"); + eprintln!(" passlock import-csv myPass123 ~/lastpass-export.csv"); + std::process::exit(1); + } + let password = &args[2]; + let input_file = &args[3]; + backup::import_csv(&active_vault, password, input_file)?; + } + "import-json" => { + if args.len() < 4 { + eprintln!("Usage: passlock import_json "); + eprintln!(); + eprintln!("Import passwords from JSON file."); + eprintln!(); + eprintln!("Example:"); + eprintln!(" passlock import-json myPass123 ~/passwords.json"); + std::process::exit(1); + } + let password = &args[2]; + let input_file = &args[3]; + backup::import_json(&active_vault, password, input_file)?; + } "info" => { handle_icmd(&args[2..])?; } @@ -94,20 +196,6 @@ fn main() -> Result<(), Box> { Ok(()) } -fn create_vault(password: &str) -> Result<(), Box> { - if storage::vt_exi() { - return Err("Vault already exists".into()); - } - - let salt = crypto::gen_salt(); - let vault = Vault::new(salt); - - storage::svv(&vault, password)?; - - println!("[✔] Vault created successfully."); - Ok(()) -} - fn unlock_vault(password: &str) -> Result<(), Box> { let vault = storage::ld_vt(password)?; println!("[✔] Vault unlocked successfully."); @@ -129,6 +217,353 @@ fn sync_vault(password: &str) -> Result<(), Box> { storage::svv(&vault, password)?; println!("[✔] Vault synced successfully."); + + println!("[...] Creating backup..."); + let cfg = config::load_config().unwrap_or_default(); + backup::create_backup(&cfg.active_vault, cfg.max_backups, false)?; + + Ok(()) +} + +/// Automatically backups vault after TUI closes +/// Called by TUI when exiting +/// Only creates backup if vault was modified +/// +/// # Errors +/// Returns an error if: +/// * Backup creation fails +/// * Configuration cannot be loaded +/// * Vault cannot be accessed +pub fn auto_back() -> Result<(), Box> { + let cfg = config::load_config().unwrap_or_default(); + + if !cfg.auto_backup { + return Ok(()); + } + + if !config::vault_exists(&cfg.active_vault) { + return Ok(()); + } + + println!("auto backup in progress"); + let (backup_name, was_created) = + backup::create_backup(&cfg.active_vault, cfg.max_backups, false)?; + + if was_created { + println!("auto backup completed {backup_name}"); + } + + Ok(()) +} + +/// Handle backup subcommands +#[allow(clippy::too_many_lines)] +fn handle_backup_cmd(args: &[String]) -> Result<(), Box> { + if args.is_empty() { + println!("Backup Commands:"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + println!("Usage: passlock backup "); + println!(); + println!("Subcommands:"); + println!(" create Create manual backup"); + println!(" list List all backups"); + println!(" restore Restore from backup"); + println!(); + println!("Examples:"); + println!(" passlock backup create myPassword123"); + println!(" passlock backup list"); + println!(" passlock backup restore latest myPassword123"); + println!(" passlock backup restore backup_2026-02-28_10-30-45.vault myPassword123"); + return Ok(()); + } + + match args[0].as_str() { + "create" => { + if args.len() < 2 { + eprintln!("Usage: passlock backup create "); + std::process::exit(1); + } + let password = &args[1]; + + let _vault = storage::ld_vt(password)?; + println!("[✔] Password verified"); + + let cfg = config::load_config().unwrap_or_default(); + backup::create_backup(&cfg.active_vault, cfg.max_backups, true)?; + println!("[✔] Manual backup created successfully"); + } + "list" => { + let cfg = config::load_config().unwrap_or_default(); + let backups = backup::ls_backs(&cfg.active_vault)?; + + if backups.is_empty() { + println!("[!] No backups found for vault '{}'", cfg.active_vault); + println!(); + println!("Create your first backup with:"); + println!(" passlock backup create "); + } else { + println!("Available Backups for vault '{}':", cfg.active_vault); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + println!("{:<40} {:<12} Created", "Filename", "Size"); + println!("{}", "─".repeat(70)); + + for (filename, size, created) in &backups { + let size_kb = if *size > 1024 { + format!("{} KB", *size / 1024) + } else { + format!("{} bytes", *size) + }; + println!("{filename:<40} {size_kb:<12} {created}"); + } + + println!(); + println!("Total backups: {}", backups.len()); + println!(); + println!("Restore a backup with:"); + println!(" passlock backup restore "); + } + } + "restore" => { + if args.len() < 2 { + eprintln!("Usage: passlock backup restore "); + eprintln!(); + eprintln!("Examples:"); + eprintln!( + " passlock backup restore backup_2026-02-28_10-30-45.vault myPassword123" + ); + eprintln!(" passlock backup restore latest myPassword123"); + std::process::exit(1); + } + + let cfg = config::load_config().unwrap_or_default(); + + if args[1] == "latest" { + if args.len() < 3 { + eprintln!("Usage: passlock backup restore latest "); + std::process::exit(1); + } + let password = &args[2]; + + let backups = backup::ls_backs(&cfg.active_vault)?; + if backups.is_empty() { + return Err("No backups found for this vault".into()); + } + + let latest_backup = &backups[0].0; + println!("[i] Restoring latest backup: {latest_backup}"); + backup::restore_backup(&cfg.active_vault, latest_backup, password)?; + println!("[✔] Vault restored to latest backup successfully"); + } else { + if args.len() < 3 { + eprintln!("Usage: passlock backup restore "); + std::process::exit(1); + } + let backup_name = &args[1]; + let password = &args[2]; + + backup::restore_backup(&cfg.active_vault, backup_name, password)?; + println!("[✔] Backup restored successfully"); + } + } + _ => { + println!("Unknown backup subcommand: {}", args[0]); + println!(); + println!("Available subcommands: create, list, restore"); + } + } + + Ok(()) +} + +/// Handle vault management subcommands +#[allow(clippy::too_many_lines)] +fn handle_vault_cmd(args: &[String]) -> Result<(), Box> { + if args.is_empty() { + println!("Vault Management Commands:"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + println!("Usage: passlock vault "); + println!(); + println!("Subcommands:"); + println!(" create Create new vault"); + println!(" list List all vaults"); + println!(" use Set active vault"); + println!(" info Show current vault info"); + println!(" delete Delete vault"); + println!(" rename Rename vault"); + println!(); + println!("Examples:"); + println!(" passlock vault create personal myPass123"); + println!(" passlock vault create work workPass456"); + println!(" passlock vault list"); + println!(" passlock vault use work"); + return Ok(()); + } + + match args[0].as_str() { + "create" => { + if args.len() < 3 { + eprintln!("Usage: passlock vault create "); + eprintln!(); + eprintln!("Example:"); + eprintln!(" passlock vault create personal myPassword123"); + std::process::exit(1); + } + let vault_name = &args[1]; + let password = &args[2]; + + if config::vault_exists(vault_name) { + return Err(format!("Vault '{vault_name}' already exists").into()); + } + + let salt = crypto::gen_salt(); + let vault = Vault::new(salt); + + storage::save_vault_to(vault_name, &vault, password)?; + + println!("[✔] Vault '{vault_name}' created successfully"); + println!("[i] Backups will be created automatically as you add passwords"); + + let mut cfg = config::load_config()?; + cfg.active_vault.clone_from(vault_name); + config::save_config(&cfg)?; + println!("[✔] Vault '{vault_name}' set as active"); + } + "list" => { + let vaults = config::list_vaults()?; + let cfg = config::load_config()?; + + if vaults.is_empty() { + println!("[!] No vaults found"); + println!(); + println!("Create your first vault with:"); + println!(" passlock vault create "); + } else { + println!("Available Vaults:"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + for vault_name in vaults { + let marker = if vault_name == cfg.active_vault { + " (active)" + } else { + "" + }; + println!(" • {vault_name}{marker}"); + } + println!(); + println!("Switch vault with: passlock vault use "); + } + } + "use" => { + if args.len() < 2 { + eprintln!("Usage: passlock vault use "); + eprintln!(); + eprintln!("Example:"); + eprintln!(" passlock vault use work"); + std::process::exit(1); + } + let vault_name = &args[1]; + + if !config::vault_exists(vault_name) { + return Err(format!("Vault '{vault_name}' does not exist").into()); + } + + let mut cfg = config::load_config()?; + cfg.active_vault.clone_from(vault_name); + config::save_config(&cfg)?; + + println!("[✔] Active vault set to: {vault_name}"); + } + "info" => { + let cfg = config::load_config()?; + let vaults = config::list_vaults()?; + + println!("Vault Information:"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + println!("Active vault: {}", cfg.active_vault); + println!("Total vaults: {}", vaults.len()); + println!(); + + if config::vault_exists(&cfg.active_vault) { + let vault_path = config::get_vault_path(&cfg.active_vault); + if let Ok(metadata) = std::fs::metadata(&vault_path) { + let size_kb = metadata.len() / 1024; + if size_kb > 0 { + println!("Vault size: {size_kb} KB"); + } else { + println!("Vault size: {} bytes", metadata.len()); + } + } + + let backups = backup::ls_backs(&cfg.active_vault).unwrap_or_else(|_| Vec::new()); + println!("Backups: {} available", backups.len()); + } else { + println!("Active vault '{}' not found!", cfg.active_vault); + } + + println!(); + println!("Configuration:"); + println!(" Auto-backup: {}", cfg.auto_backup); + println!(" Max backups: {}", cfg.max_backups); + println!(" Clipboard timeout: {}s", cfg.clipboard_timeout); + println!(" Refresh rate: {}ms", cfg.refresh_rate); + } + "delete" => { + if args.len() < 2 { + eprintln!("Usage: passlock vault delete "); + eprintln!(); + eprintln!("Example:"); + eprintln!(" passlock vault delete old-vault"); + std::process::exit(1); + } + let vault_name = &args[1]; + + let cfg = config::load_config()?; + if vault_name == &cfg.active_vault { + return Err("Cannot delete active vault! Switch to another vault first.".into()); + } + + println!("WARNING: This will delete vault '{vault_name}' and all its backups."); + println!("Are you sure? (y/n)"); + + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + + if line.trim().to_lowercase() == "y" { + config::delete_vault(vault_name)?; + } else { + println!("[!] Deletion cancelled"); + } + } + "rename" => { + if args.len() < 3 { + eprintln!("Usage: passlock vault rename "); + eprintln!(); + eprintln!("Example:"); + eprintln!(" passlock vault rename work company"); + std::process::exit(1); + } + let old_name = &args[1]; + let new_name = &args[2]; + + config::rename_vault(old_name, new_name)?; + + let mut cfg = config::load_config()?; + if cfg.active_vault == *old_name { + cfg.active_vault.clone_from(new_name); + config::save_config(&cfg)?; + } + } + _ => { + println!("Unknown vault subcommand: {}", args[0]); + println!(); + println!("Available subcommands: create, list, use, info, delete, rename"); + } + } + Ok(()) } @@ -170,6 +605,14 @@ fn handle_icmd(args: &[String]) -> Result<(), Box> { } } } + + let cfg = config::load_config().unwrap_or_default(); + let backups = backup::ls_backs(&cfg.active_vault).unwrap_or_else(|_| Vec::new()); + println!( + " Backups: {} available (vault: {})", + backups.len(), + cfg.active_vault + ); } else { println!("Vault Status:"); println!(" No vault found"); @@ -195,6 +638,37 @@ fn print_usage() { println!("USAGE:"); println!(" passlock [OPTIONS]"); println!(); + println!("VAULT COMMANDS:"); + println!(" unlock Unlock and verify vault"); + println!(" sync Sync vault changes"); + println!(" tui Launch TUI interface"); + println!(); + println!("VAULT MANAGEMENT:"); + println!(" vault create Create new vault"); + println!(" vault list List all vaults"); + println!(" vault use Set active vault"); + println!(" vault info Show vault info"); + println!(" vault delete Delete vault"); + println!(" vault rename Rename vault"); + println!(); + println!("BACKUP COMMANDS (Encrypted):"); + println!(" backup create Create manual backup"); + println!(" backup list List all backups"); + println!(" backup restore Restore from backup"); + println!(); + println!(" export Export vault (encrypted)"); + println!(" import Import vault (encrypted)"); + println!(); + println!("PASSWORD MANAGER MIGRATION (Plaintext - USE WITH CAUTION):"); + println!(" export-csv Export to CSV (for other PMs)"); + println!(" export-json Export to JSON (for other PMs)"); + println!(" import-csv Import from CSV"); + println!(" import-json Import from JSON"); + println!(); + println!("INFO COMMANDS:"); + println!(" info [cpu] Show system information"); + println!(" version Show version"); + println!(" help Show this help"); println!("COMMANDS:"); println!(" create Create a new vault"); println!(" unlock Unlock and verify vault"); @@ -204,10 +678,5 @@ fn print_usage() { println!(" version Show version"); println!(" help Show this help"); println!(); - println!("EXAMPLES:"); - println!(" passlock create mySecurePassword123"); - println!(" passlock tui"); - println!(" passlock info cpu"); - println!(); - println!("For more info: https://github.com/hachimamma/Passlock"); + println!("For more info: https://github.com/hachimamma/Passlock/blob/main/README.md"); } diff --git a/src/storage.rs b/src/storage.rs index 8d918b3..aac2586 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -3,28 +3,27 @@ use crate::models::Vault; use std::fs; use std::path::PathBuf; -fn vt_p() -> PathBuf { - let home = dirs::home_dir().expect("no home"); - home.join(".passlock.vault") +/// Get vault path for a specific vault register +fn vt_p_named(vault_name: &str) -> PathBuf { + crate::config::get_vault_path(vault_name) } -fn tmp_p() -> PathBuf { - let home = dirs::home_dir().expect("no home"); - home.join(".passlock.temp") -} +/// Save vault to a specific vault register +pub fn svv_named(vault_name: &str, v: &Vault, pwd: &str) -> Result<(), String> { + let vault_path = vt_p_named(vault_name); + + if let Some(parent) = vault_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } -pub fn svv(v: &Vault, pwd: &str) -> Result<(), String> { let j = serde_json::to_string(v).map_err(|e| e.to_string())?; let j_bytes = j.as_bytes(); - let enc_d = crypto::enc(j_bytes, pwd, &v.s)?; - let salt_bytes = hex::decode(&v.s).map_err(|_| "Invalid salt")?; - let mut final_data = Vec::new(); - final_data.extend_from_slice(&salt_bytes); - final_data.extend_from_slice(&enc_d); - - fs::write(vt_p(), final_data).map_err(|e| e.to_string())?; + let mut __final_data__ = Vec::new(); + __final_data__.extend_from_slice(&salt_bytes); + __final_data__.extend_from_slice(&enc_d); + fs::write(vault_path, __final_data__).map_err(|e| e.to_string())?; let tmp_j = serde_json::to_string(v).map_err(|e| e.to_string())?; fs::write(tmp_p(), tmp_j).map_err(|e| e.to_string())?; @@ -32,20 +31,23 @@ pub fn svv(v: &Vault, pwd: &str) -> Result<(), String> { Ok(()) } -pub fn ld_vt(pwd: &str) -> Result { - let data = fs::read(vt_p()).map_err(|_| "vault not found")?; +/// Load vault from a specific vault register +pub fn ld_vt_named(vault_name: &str, pwd: &str) -> Result { + let vault_path = vt_p_named(vault_name); + + if !vault_path.exists() { + return Err(format!("Vault '{vault_name}' not found")); + } + let data = fs::read(vault_path).map_err(|_| "vault not found")?; if data.len() < 16 { return Err("corrupt vault".to_string()); } - let salt_bytes = &data[0..16]; let enc_data = &data[16..]; let salt = hex::encode(salt_bytes); - let dec_data = crypto::dec(enc_data, pwd, &salt)?; let dec_str = String::from_utf8(dec_data).map_err(|_| "invalid data")?; - let v: Vault = serde_json::from_str(&dec_str).map_err(|e| e.to_string())?; let tmp_j = serde_json::to_string(&v).map_err(|e| e.to_string())?; @@ -54,6 +56,44 @@ pub fn ld_vt(pwd: &str) -> Result { Ok(v) } +/// Check for a specific vault +pub fn vt_exi_named(vault_name: &str) -> bool { + vt_p_named(vault_name).exists() +} + +/// Helper for vault creation +pub fn save_vault_to(vault_name: &str, v: &Vault, pwd: &str) -> Result<(), String> { + svv_named(vault_name, v, pwd) +} + +// backward compatible + +/// Get vault path +fn _vt_p() -> PathBuf { + let cfg = crate::config::load_config().unwrap_or_default(); + vt_p_named(&cfg.active_vault) +} + +/// Get temp file path +fn tmp_p() -> PathBuf { + let home = dirs::home_dir().expect("no home"); + home.join(".passlock.temp") +} + +/// Save vault +pub fn svv(v: &Vault, pwd: &str) -> Result<(), String> { + let cfg = crate::config::load_config().unwrap_or_default(); + svv_named(&cfg.active_vault, v, pwd) +} + +/// Load vault +pub fn ld_vt(pwd: &str) -> Result { + let cfg = crate::config::load_config().unwrap_or_default(); + ld_vt_named(&cfg.active_vault, pwd) +} + +/// Check if vault exists pub fn vt_exi() -> bool { - vt_p().exists() + let cfg = crate::config::load_config().unwrap_or_default(); + vt_exi_named(&cfg.active_vault) } diff --git a/src/ui/app.rs b/src/ui/app.rs index 6066e36..24e0017 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -50,6 +50,9 @@ pub struct App { pub n_entry_totp: String, pub show_totp_codes: bool, pub clipboard_timeout: u64, + pub menu_click_map: Vec<(u16, u16, u16, u16, usize)>, + pub rr_ms: u64, + pub rr_cmap: Vec<(u16, u16, u16, u16)>, } impl App { @@ -98,6 +101,9 @@ impl App { n_entry_totp: String::new(), show_totp_codes: true, clipboard_timeout: 30, + menu_click_map: Vec::new(), + rr_ms: 100, + rr_cmap: Vec::new(), } } diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index 518a685..b66d3ca 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -591,10 +591,6 @@ pub fn handle_settings_screen(app: &mut App, key: KeyCode) { .unwrap_or(0); } KeyCode::Char('2') => { - // auto save toggle (future feature) - app.set_msg("Auto-save coming soon!", MessageType::Info); - } - KeyCode::Char('3') => { app.clipboard_timeout = match app.clipboard_timeout { 10 => 30, 30 => 60, @@ -611,6 +607,10 @@ pub fn handle_settings_screen(app: &mut App, key: KeyCode) { }; app.set_msg(&msg, MessageType::Success); } + KeyCode::Char('3') => { + // auto save toggle (future feature) + app.set_msg("Auto-save coming soon!", MessageType::Info); + } KeyCode::Enter => match app.settings_menu_index { 0 => { use super::colors::Theme; @@ -629,9 +629,6 @@ pub fn handle_settings_screen(app: &mut App, key: KeyCode) { }; } 1 => { - app.set_msg("Auto-save coming soon!", MessageType::Info); - } - 2 => { app.clipboard_timeout = match app.clipboard_timeout { 10 => 30, 30 => 60, @@ -648,6 +645,9 @@ pub fn handle_settings_screen(app: &mut App, key: KeyCode) { }; app.set_msg(&msg, MessageType::Success); } + 2 => { + app.set_msg("Auto-save coming soon!", MessageType::Info); + } _ => {} }, KeyCode::Esc => { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2d96307..bd4eefe 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -177,120 +177,166 @@ fn run_app( terminal.draw(|f| ui(f, app))?; - match event::read()? { - Event::Mouse(mouse_event) => match mouse_event.kind { - MouseEventKind::Down(MouseButton::Right) => { - if app.screen == Screen::ViewPasswords || app.screen == Screen::SearchPassword { - let y = mouse_event.row; - let x = mouse_event.column; - - if (5..=170).contains(&x) { - for (start_row, end_row, entry_idx) in &app.entry_row_map { - if y >= *start_row && y <= *end_row { - app.context_menu_visible = true; - app.context_menu_entry_idx = *entry_idx; - app.context_menu_selected = 0; - - let term_width = 174u16; - let menu_width = 24u16; - app.context_menu_x = if x + menu_width > term_width { - term_width.saturating_sub(menu_width) - } else { - x - }; - app.context_menu_y = y; - break; + if event::poll(std::time::Duration::from_millis(app.rr_ms))? { + match event::read()? { + Event::Mouse(mouse_event) => match mouse_event.kind { + MouseEventKind::Down(MouseButton::Right) => { + if app.screen == Screen::ViewPasswords + || app.screen == Screen::SearchPassword + { + let y = mouse_event.row; + let x = mouse_event.column; + + if (5..=170).contains(&x) { + for (start_row, end_row, entry_idx) in &app.entry_row_map { + if y >= *start_row && y <= *end_row { + app.context_menu_visible = true; + app.context_menu_entry_idx = *entry_idx; + app.context_menu_selected = 0; + + let term_width = 174u16; + let menu_width = 24u16; + app.context_menu_x = if x + menu_width > term_width { + term_width.saturating_sub(menu_width) + } else { + x + }; + app.context_menu_y = y; + break; + } } } } } - } - MouseEventKind::Down(MouseButton::Left) => { - if app.context_menu_visible { - handle_context_menu_click(app, mouse_event.column, mouse_event.row); - } else { - app.context_menu_visible = false; - } - } - MouseEventKind::ScrollUp => { - if app.screen == Screen::ViewPasswords && app.selected_entry > 0 { - app.selected_entry -= 1; - } - } - MouseEventKind::ScrollDown => { - if app.screen == Screen::ViewPasswords - && app.selected_entry < app.entry_disp.len().saturating_sub(1) - { - app.selected_entry += 1; - } - } - _ => {} - }, - Event::Key(key_event) => { - if key_event.kind == KeyEventKind::Press { - if app.context_menu_visible { - use crate::ui::clipboard; - use screens::MessageType; - let timeout = app.clipboard_timeout; - - match key_event.code { - KeyCode::Down => { - if app.context_menu_selected < 4 { - app.context_menu_selected += 1; - } - } - KeyCode::Up => { - if app.context_menu_selected > 0 { - app.context_menu_selected -= 1; + MouseEventKind::Down(MouseButton::Left) => { + if app.context_menu_visible { + handle_context_menu_click(app, mouse_event.column, mouse_event.row); + } else if app.screen == Screen::MainMenu { + let x = mouse_event.column; + let y = mouse_event.row; + + for (y_start, y_end, x_start, x_end, menu_idx) in &app.menu_click_map { + if y >= *y_start && y <= *y_end && x >= *x_start && x < *x_end { + app.selected_menu = *menu_idx; + app.msg.clear(); + + match *menu_idx { + 0 => { + app.screen = Screen::ViewPasswords; + app.active_tf = None; + app.search_query.clear(); + if let Some(ref vault) = app.vault { + app.entry_disp = vault.e.clone(); + } + } + 1 => { + app.screen = Screen::AddPassword; + app.ca_form(); + } + 2 => { + app.screen = Screen::SearchPassword; + app.search_query.clear(); + app.entry_disp.clear(); + } + 3 => { + app.screen = Screen::FilterByTag; + app.select_tf = 0; + app.filter_bt(None); + } + 4 => { + app.screen = Screen::GeneratePassword; + app.input_buffer = String::from("16"); + app.gen_pwd.clear(); + } + 5 => { + app.screen = Screen::DeletePassword; + app.input_buffer.clear(); + if app.entry_disp.is_empty() { + if let Some(ref vault) = app.vault { + app.entry_disp = vault.e.clone(); + } + } + } + 6 => { + app.should_quit = true; + } + _ => {} + } + break; } } - KeyCode::Enter => { - let entry_idx = app.context_menu_entry_idx; - if entry_idx >= app.entry_disp.len() { - app.context_menu_visible = false; - continue; - } - let entry = app.entry_disp[entry_idx].clone(); + for (y_start, y_end, x_start, x_end) in &app.rr_cmap { + if y >= *y_start && y < *y_end && x >= *x_start && x < *x_end { + let center_x = (x_start + x_end) / 2; - match app.context_menu_selected { - 0 => { - let result = - clipboard::copy_with_timeout(&entry.p, timeout); - app.clipboard_countdown = if result.success { - Some(result.expires_at) - } else { - None - }; + if x < center_x { + app.rr_ms = (app.rr_ms.saturating_sub(50)).max(50); app.set_msg( - &result.message, - if result.success { - MessageType::Success - } else { - MessageType::Error - }, + &format!("Refresh: {}ms", app.rr_ms), + screens::MessageType::Info, ); - } - 1 => { - let result = - clipboard::copy_with_timeout(&entry.u, timeout); - app.clipboard_countdown = if result.success { - Some(result.expires_at) - } else { - None - }; + } else { + app.rr_ms = (app.rr_ms + 50).min(1000); app.set_msg( - &result.message, - if result.success { - MessageType::Success - } else { - MessageType::Error - }, + &format!("Refresh: {}ms", app.rr_ms), + screens::MessageType::Info, ); } - 2 => { - if let Some(ref url) = entry.url { - let result = clipboard::copy_with_timeout(url, timeout); + break; + } + } + + app.context_menu_visible = false; + } else { + app.context_menu_visible = false; + } + } + MouseEventKind::ScrollUp => { + if app.screen == Screen::ViewPasswords && app.selected_entry > 0 { + app.selected_entry -= 1; + } + } + MouseEventKind::ScrollDown => { + if app.screen == Screen::ViewPasswords + && app.selected_entry < app.entry_disp.len().saturating_sub(1) + { + app.selected_entry += 1; + } + } + _ => {} + }, + Event::Key(key_event) => { + if key_event.kind == KeyEventKind::Press { + if app.context_menu_visible { + use crate::ui::clipboard; + use screens::MessageType; + let timeout = app.clipboard_timeout; + + match key_event.code { + KeyCode::Down => { + if app.context_menu_selected < 4 { + app.context_menu_selected += 1; + } + } + KeyCode::Up => { + if app.context_menu_selected > 0 { + app.context_menu_selected -= 1; + } + } + KeyCode::Enter => { + let entry_idx = app.context_menu_entry_idx; + if entry_idx >= app.entry_disp.len() { + app.context_menu_visible = false; + continue; + } + + let entry = app.entry_disp[entry_idx].clone(); + + match app.context_menu_selected { + 0 => { + let result = + clipboard::copy_with_timeout(&entry.p, timeout); app.clipboard_countdown = if result.success { Some(result.expires_at) } else { @@ -304,81 +350,123 @@ fn run_app( MessageType::Error }, ); - } else { + } + 1 => { + let result = + clipboard::copy_with_timeout(&entry.u, timeout); + app.clipboard_countdown = if result.success { + Some(result.expires_at) + } else { + None + }; app.set_msg( - "No URL for this entry!", - MessageType::Error, + &result.message, + if result.success { + MessageType::Success + } else { + MessageType::Error + }, ); } + 2 => { + if let Some(ref url) = entry.url { + let result = + clipboard::copy_with_timeout(url, timeout); + app.clipboard_countdown = if result.success { + Some(result.expires_at) + } else { + None + }; + app.set_msg( + &result.message, + if result.success { + MessageType::Success + } else { + MessageType::Error + }, + ); + } else { + app.set_msg( + "No URL for this entry!", + MessageType::Error, + ); + } + } + 3 => { + let entry_id = entry.id.clone(); + app.load_efe(&entry_id); + } + 4 => { + app.selected_entry = entry_idx; + app.screen = Screen::ViewHistory; + } + _ => {} } - 3 => { - let entry_id = entry.id.clone(); - app.load_efe(&entry_id); - } - 4 => { - app.selected_entry = entry_idx; - app.screen = Screen::ViewHistory; - } - _ => {} + app.context_menu_visible = false; + } + _ => { + app.context_menu_visible = false; } - app.context_menu_visible = false; - } - _ => { - app.context_menu_visible = false; } + continue; } - continue; - } - match app.screen { - Screen::VaultCheck => {} - Screen::CreateVault => handle_cvi(app, key_event.code), - Screen::UnlockVault => handle_uvi(app, key_event.code), - Screen::MainMenu => { - if handle_mmi(app, key_event.code) { - return Ok(()); + match app.screen { + Screen::VaultCheck => {} + Screen::CreateVault => handle_cvi(app, key_event.code), + Screen::UnlockVault => handle_uvi(app, key_event.code), + Screen::MainMenu => { + if handle_mmi(app, key_event.code) { + if let Err(e) = crate::auto_back() { + eprintln!("[!] Auto-backup failed: {e}"); + } + return Ok(()); + } } - } - Screen::ViewPasswords => handle_vpi(app, key_event.code), - Screen::AddPassword => { - if key_event.code == KeyCode::Char('s') - && key_event - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - { - app.add_entry(); - } else { - handle_api(app, key_event.code); + Screen::ViewPasswords => handle_vpi(app, key_event.code), + Screen::AddPassword => { + if key_event.code == KeyCode::Char('s') + && key_event + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + app.add_entry(); + } else { + handle_api(app, key_event.code); + } } - } - Screen::EditPassword => { - if key_event.code == KeyCode::Char('s') - && key_event - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - { - app.edit_entry(); - } else { - handle_epi(app, key_event.code); + Screen::EditPassword => { + if key_event.code == KeyCode::Char('s') + && key_event + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + app.edit_entry(); + } else { + handle_epi(app, key_event.code); + } } - } - Screen::ViewHistory => handle_vhi(app, key_event.code), - Screen::SearchPassword => handle_si(app, key_event.code), - Screen::GeneratePassword => handle_gi(app, key_event.code), - Screen::DeletePassword => handle_di(app, key_event.code), - Screen::FilterByTag => handle_tfi(app, key_event.code), - Screen::ThemeSelector => handle_theme_selector(app, key_event.code), - Screen::OptionsMenu => { - if handle_options_menu(app, key_event.code) { - return Ok(()); + Screen::ViewHistory => handle_vhi(app, key_event.code), + Screen::SearchPassword => handle_si(app, key_event.code), + Screen::GeneratePassword => handle_gi(app, key_event.code), + Screen::DeletePassword => handle_di(app, key_event.code), + Screen::FilterByTag => handle_tfi(app, key_event.code), + Screen::ThemeSelector => handle_theme_selector(app, key_event.code), + Screen::OptionsMenu => { + if handle_options_menu(app, key_event.code) { + if let Err(e) = crate::auto_back() { + eprintln!("[!] Auto-backup failed: {e}"); + } + return Ok(()); + } } + Screen::Help => handle_help_screen(app, key_event.code), + Screen::Settings => handle_settings_screen(app, key_event.code), } - Screen::Help => handle_help_screen(app, key_event.code), - Screen::Settings => handle_settings_screen(app, key_event.code), } } + _ => {} } - _ => {} } } Ok(()) diff --git a/src/ui/widgets/menu.rs b/src/ui/widgets/menu.rs index dcbf749..bff24f3 100644 --- a/src/ui/widgets/menu.rs +++ b/src/ui/widgets/menu.rs @@ -1,192 +1,501 @@ use super::super::app::App; use super::super::colors::ThemeColors; use super::super::screens::MessageType; +use crate::vault_ffi; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + widgets::{Block, BorderType, Borders, List, ListItem, Paragraph}, Frame, }; #[allow(clippy::too_many_lines)] -pub fn draw_main_menu(f: &mut Frame, size: Rect, app: &App) { +pub fn draw_main_menu(f: &mut Frame, size: Rect, app: &mut App) { + let outer_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.fg())) + .style(Style::default().bg(app.theme.bg0())); + f.render_widget(outer_block, size); + let main_layout = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([Constraint::Length(5), Constraint::Min(10)]) + .direction(Direction::Horizontal) + .margin(3) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) .split(size); - let header_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.yellow())) - .style(Style::default().bg(app.theme.bg0())); + let has_vault = app.vault.is_some(); + let sysinfo_height = if has_vault { 8 } else { 4 }; + + let left_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // title + Constraint::Length(sysinfo_height), // system info and crypto + Constraint::Min(0), // menu + ]) + .split(main_layout[0]); - let vault_info = if let Some(ref vault) = app.vault { - let tag_count = app.all_tags.len(); - let disp_count = app.entry_disp.len(); - let total_count = vault.e.len(); - let filter_indicator = if disp_count == total_count { - String::new() + let title = Paragraph::new(Line::from(vec![Span::styled( + "PASSLOCK", + Style::default() + .fg(app.theme.red()) + .add_modifier(Modifier::BOLD), + )])) + .alignment(Alignment::Center); + f.render_widget(title, left_layout[0]); + + let info_rl = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(55), // system info + Constraint::Percentage(45), // crypto + ]) + .split(left_layout[1]); + + if let Some(ref vault) = app.vault { + let total = vault.e.len(); + let with_2fa = vault.e.iter().filter(|e| e.totp_secret.is_some()).count(); + let tags = app.all_tags.len(); + let cipher = vault_ffi::get_cipher(); + + let cipher_display = if info_rl[0].width < 30 { + if vault_ffi::aes_sup() { + "AES-256-GCM" + } else { + "ChaCha20-Poly1305" + } + } else if info_rl[0].width < 45 { + if vault_ffi::aes_sup() { + "AES-256-GCM (HW)" + } else { + "ChaCha20-Poly1305" + } } else { - format!(" (Filtered: {disp_count})") + cipher }; - vec![ + + let info_lines = vec![ Line::from(vec![Span::styled( - "█▓▒░ PASSLOCK ░▒▓█", + "System Info", Style::default() - .fg(app.theme.orange()) + .fg(app.theme.aqua()) .add_modifier(Modifier::BOLD), )]), Line::from(""), Line::from(vec![ - Span::styled("Vault: ", Style::default().fg(app.theme.gray())), Span::styled( - "UNLOCKED ", + "Build: v", Style::default() - .fg(app.theme.green()) + .fg(app.theme.gray()) .add_modifier(Modifier::BOLD), ), - Span::styled("│ ", Style::default().fg(app.theme.gray())), Span::styled( - format!("{total_count} passwords{filter_indicator} "), - Style::default().fg(app.theme.blue()), + env!("CARGO_PKG_VERSION"), + Style::default().fg(app.theme.yellow()), + ), + ]), + Line::from(vec![ + Span::styled( + "Cipher: ", + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), ), - Span::styled("│ ", Style::default().fg(app.theme.gray())), + Span::styled(cipher_display, Style::default().fg(app.theme.purple())), + ]), + Line::from(vec![ Span::styled( - format!("{tag_count} tags"), - Style::default().fg(app.theme.purple()), + "Entries: ", + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), ), + Span::styled(format!("{total}"), Style::default().fg(app.theme.green())), ]), - ] - } else { - vec![ - Line::from(Span::styled( - "PASSLOCK", - Style::default().fg(app.theme.red()), - )), - Line::from(""), - Line::from("No vault loaded"), - ] - }; + Line::from(vec![ + Span::styled( + "Stats: ", + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{with_2fa} 2FA │ {tags} tags"), + Style::default().fg(app.theme.blue()), + ), + ]), + ]; - let header = Paragraph::new(vault_info) - .block(header_block) - .alignment(Alignment::Center); - f.render_widget(header, main_layout[0]); + let info_box = Paragraph::new(info_lines).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.aqua())), + ); + f.render_widget(info_box, info_rl[0]); - let content_layout = Layout::default() - .direction(Direction::Horizontal) - .margin(0) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(main_layout[1]); + let is_aes = vault_ffi::aes_sup(); + let crypto_lines = vec![ + Line::from(vec![Span::styled( + "Crypto", + Style::default() + .fg(app.theme.red()) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![Span::styled( + "┌──────────┐", + Style::default().fg(app.theme.gray()), + )]), + Line::from(vec![ + Span::styled("│ ", Style::default().fg(app.theme.gray())), + Span::styled( + if is_aes { "AES-NI" } else { "ChaCha" }, + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" │", Style::default().fg(app.theme.gray())), + ]), + Line::from(vec![Span::styled( + "└──────────┘", + Style::default().fg(app.theme.gray()), + )]), + Line::from(vec![Span::styled( + "Hardware", + Style::default().fg(if is_aes { + app.theme.green() + } else { + app.theme.red() + }), + )]), + ]; - let left_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.green())) - .title("═══ PASSWORDS ═══") - .title_alignment(Alignment::Center) - .style(Style::default().bg(app.theme.bg0())); + let crypto_box = Paragraph::new(crypto_lines) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.red())), + ); + f.render_widget(crypto_box, info_rl[1]); + } - let left_items = [ - ("1", "View All", "Browse vault"), - ("2", "Add New", "Create entry"), + let all_items = [ + ("1", "View Passwords", "Browse all entries"), + ("2", "Add Password", "Create new entry"), ("3", "Search", "Find passwords"), + ("4", "Filter by Tags", "Sort entries"), + ("5", "Generate Password", "Random secure"), + ("6", "Delete Entry", "Remove password"), + ("7", "Exit", "Lock vault"), ]; - let left_list: Vec = left_items + let menu_area = left_layout[2]; + let avail_h = menu_area.height.saturating_sub(2); + let mlp_item = 2; + let items_count = all_items.len(); + let min_req = u16::try_from(mlp_item * items_count).unwrap_or(u16::MAX); + + let spacing = if avail_h > min_req + (u16::try_from(items_count).unwrap_or(0) * 2) { + 2 + } else { + i32::from(avail_h > min_req + u16::try_from(items_count).unwrap_or(0)) + }; + + let menu_list: Vec = all_items .iter() .enumerate() .map(|(i, (num, title, desc))| { - let is_selected = app.selected_section == 0 && i == app.selected_menu; - let style = if is_selected { + let is_selected = i == app.selected_menu; + + let title_style = if is_selected { Style::default() .fg(app.theme.yellow()) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(app.theme.fg()) + Style::default() + .fg(app.theme.fg()) + .add_modifier(Modifier::BOLD) }; + let prefix = if is_selected { "▶ " } else { " " }; - let lines = vec![ - Line::from(vec![ - Span::styled(prefix, Style::default().fg(app.theme.yellow())), - Span::styled(format!("[{num}] "), Style::default().fg(app.theme.orange())), - Span::styled(*title, style), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled(*desc, Style::default().fg(app.theme.gray())), - ]), - Line::from(""), - ]; - ListItem::new(lines) - }) - .collect(); - let left = List::new(left_list).block(left_block); - f.render_widget(left, content_layout[0]); + let mut lines = vec![]; - let right_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.purple())) - .title("═══ TOOLS ═══") - .title_alignment(Alignment::Center) - .style(Style::default().bg(app.theme.bg0())); + if i == 0 && spacing > 0 { + lines.push(Line::from("")); + } - let right_items = [ - ("4", "Filter Tags", "Sort by tags"), - ("5", "Generate", "Random password"), - ("6", "Delete", "Remove entry"), - ("7", "Exit", "Lock & quit"), - ]; + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(app.theme.yellow())), + Span::styled( + format!("[{num}] "), + Style::default() + .fg(app.theme.orange()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(*title, title_style), + ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(*desc, Style::default().fg(app.theme.gray())), + ])); + + for _ in 0..spacing { + lines.push(Line::from("")); + } - let right_list: Vec = right_items - .iter() - .enumerate() - .map(|(i, (num, title, desc))| { - let is_selected = app.selected_section == 1 && i == app.selected_menu - 3; - let style = if is_selected { - Style::default() - .fg(app.theme.yellow()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.theme.fg()) - }; - let prefix = if is_selected { "▶ " } else { " " }; - let lines = vec![ - Line::from(vec![ - Span::styled(prefix, Style::default().fg(app.theme.yellow())), - Span::styled(format!("[{num}] "), Style::default().fg(app.theme.orange())), - Span::styled(*title, style), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled(*desc, Style::default().fg(app.theme.gray())), - ]), - Line::from(""), - ]; ListItem::new(lines) }) .collect(); - let right = List::new(right_list).block(right_block); - f.render_widget(right, content_layout[1]); + let menu_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.green())) + .title(Span::styled( + " [ Menu ] ", + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + )); + + let menu_ia = menu_block.inner(menu_area); + + let menu = List::new(menu_list).block(menu_block); + f.render_widget(menu, menu_area); + + let mut current_y = menu_ia.y; + if spacing > 0 { + current_y += 1; + } + + app.menu_click_map.clear(); + + for i in 0..all_items.len() { + let item_height = u16::try_from(2 + spacing).unwrap_or(0); + let clicky_start = current_y; + let clicky_end = current_y + 1; + + app.menu_click_map.push(( + clicky_start, + clicky_end, + menu_ia.x, + menu_ia.x + menu_ia.width, + i, + )); + + current_y += item_height; + } + + let right_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // status msg + Constraint::Length(1), // spacer + Constraint::Min(0), // pwd stats + ]) + .split(main_layout[1]); if !app.msg.is_empty() { - let msg_area = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(3)]) - .split(main_layout[1])[1]; let msg_style = match app.msg_type { MessageType::Success => Style::default().fg(app.theme.green()), MessageType::Error => Style::default().fg(app.theme.red()), MessageType::Info => Style::default().fg(app.theme.blue()), MessageType::None => Style::default().fg(app.theme.fg()), }; - let msg = Paragraph::new(app.msg.as_str()) - .style(msg_style) + + let status_msg = Paragraph::new(app.msg.as_str()) + .style(msg_style.add_modifier(Modifier::BOLD)) .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - f.render_widget(msg, msg_area); + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(msg_style), + ); + f.render_widget(status_msg, right_layout[0]); } + + if let Some(ref vault) = app.vault { + let total = vault.e.len(); + let with_2fa = vault.e.iter().filter(|e| e.totp_secret.is_some()).count(); + let with_url = vault.e.iter().filter(|e| e.url.is_some()).count(); + let with_notes = vault + .e + .iter() + .filter(|e| e.nt.as_ref().is_some_and(|n| !n.is_empty())) + .count(); + + let two_fa_pct = if total > 0 { + (with_2fa * 100) / total + } else { + 0 + }; + let url_pct = if total > 0 { + (with_url * 100) / total + } else { + 0 + }; + let notes_pct = if total > 0 { + (with_notes * 100) / total + } else { + 0 + }; + + let create_bar = |pct: usize| -> String { + let filled = pct / 10; + let mut bar = String::new(); + for i in 0..10 { + bar.push(if i < filled { '█' } else { '░' }); + } + bar + }; + + let mut stats_lines = vec![ + Line::from(vec![Span::styled( + "Password Statistics", + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![Span::styled( + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + Style::default().fg(app.theme.gray()), + )]), + Line::from(""), + Line::from(vec![Span::styled( + "Total Entries", + Style::default().fg(app.theme.gray()), + )]), + Line::from(vec![Span::styled( + format!(" {total}"), + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![Span::styled( + "2FA Protection", + Style::default().fg(app.theme.gray()), + )]), + Line::from(vec![Span::styled( + format!(" {}", create_bar(two_fa_pct)), + Style::default().fg(app.theme.green()), + )]), + Line::from(vec![Span::styled( + format!(" {with_2fa} entries ({two_fa_pct}%)"), + Style::default().fg(app.theme.green()), + )]), + Line::from(""), + Line::from(vec![Span::styled( + "With URLs", + Style::default().fg(app.theme.gray()), + )]), + Line::from(vec![Span::styled( + format!(" {}", create_bar(url_pct)), + Style::default().fg(app.theme.blue()), + )]), + Line::from(vec![Span::styled( + format!(" {with_url} entries ({url_pct}%)"), + Style::default().fg(app.theme.blue()), + )]), + Line::from(""), + Line::from(vec![Span::styled( + "With Notes", + Style::default().fg(app.theme.gray()), + )]), + Line::from(vec![Span::styled( + format!(" {}", create_bar(notes_pct)), + Style::default().fg(app.theme.purple()), + )]), + Line::from(vec![Span::styled( + format!(" {with_notes} entries ({notes_pct}%)"), + Style::default().fg(app.theme.purple()), + )]), + ]; + + if !app.all_tags.is_empty() { + stats_lines.push(Line::from("")); + stats_lines.push(Line::from(vec![Span::styled( + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + Style::default().fg(app.theme.gray()), + )])); + stats_lines.push(Line::from("")); + stats_lines.push(Line::from(vec![Span::styled( + "Top Tags", + Style::default() + .fg(app.theme.aqua()) + .add_modifier(Modifier::BOLD), + )])); + + for (tag, count) in app.all_tags.iter().take(3) { + stats_lines.push(Line::from(vec![ + Span::styled( + format!(" {tag} "), + Style::default().fg(app.theme.purple()), + ), + Span::styled(format!("({count})"), Style::default().fg(app.theme.gray())), + ])); + } + } + + let stats_box = Paragraph::new(stats_lines).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.yellow())), + ); + f.render_widget(stats_box, right_layout[2]); + } + + let rms = app.rr_ms; + let rtxt = format!(" [ - ] {rms}ms [ + ] "); + let r_width = u16::try_from(rtxt.len()).unwrap_or(u16::MAX); + + let r_area = Rect { + x: size.x + size.width - r_width - 1, + y: size.y + size.height - 1, + width: r_width, + height: 1, + }; + + let r_widget = Paragraph::new(Line::from(vec![ + Span::styled(" [ ", Style::default().fg(app.theme.gray())), + Span::styled( + "-", + Style::default() + .fg(app.theme.orange()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ] ", Style::default().fg(app.theme.gray())), + Span::styled( + format!("{rms}ms"), + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" [ ", Style::default().fg(app.theme.gray())), + Span::styled( + "+", + Style::default() + .fg(app.theme.orange()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ] ", Style::default().fg(app.theme.gray())), + ])); + f.render_widget(r_widget, r_area); + + app.rr_cmap.clear(); + app.rr_cmap.push(( + r_area.y, + r_area.y + r_area.height, + r_area.x, + r_area.x + r_area.width, + )); } diff --git a/src/ui/widgets/passwords.rs b/src/ui/widgets/passwords.rs index fd885bf..0a114c1 100644 --- a/src/ui/widgets/passwords.rs +++ b/src/ui/widgets/passwords.rs @@ -7,7 +7,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap}, Frame, }; @@ -15,22 +15,24 @@ use ratatui::{ pub fn draw_view_pwds(f: &mut Frame, size: Rect, app: &mut App) { app.entry_row_map.clear(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(size); let block = Block::default() .borders(Borders::ALL) + .border_type(BorderType::Rounded) .border_style(Style::default().fg(app.theme.green())) - .title("═══ PASSWORDS ═══") - .title_alignment(Alignment::Center) + .title(Span::styled( + " [ Passwords ] ", + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + )) .style(Style::default().bg(app.theme.bg0())); - f.render_widget(block.clone(), size); + f.render_widget(block, size); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Length(4), Constraint::Min(5)]) + .split(size); let filter_status = if let Some(ref tag) = app.active_tf { format!(" (Filtered by: {tag})") @@ -43,7 +45,7 @@ pub fn draw_view_pwds(f: &mut Frame, size: Rect, app: &mut App) { let clipboard_status = if let Some(expires_at) = app.clipboard_countdown { let now = crate::get_timestamp(); if expires_at > now { - format!(" | Clipboard clears in {}s", expires_at - now) + format!(" │ Clipboard: {}s", expires_at - now) } else { String::new() } @@ -51,26 +53,61 @@ pub fn draw_view_pwds(f: &mut Frame, size: Rect, app: &mut App) { String::new() }; - let title = Paragraph::new(format!( - "Total: {} entries{}{} | Right-click entry for options", - app.entry_disp.len(), - filter_status, - clipboard_status - )) - .style(Style::default().fg(app.theme.yellow())) - .alignment(Alignment::Center); + let title = Paragraph::new(vec![ + Line::from(vec![ + Span::styled( + format!("Total: {} entries", app.entry_disp.len()), + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(filter_status, Style::default().fg(app.theme.purple())), + Span::styled(clipboard_status, Style::default().fg(app.theme.aqua())), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "Right-click for options", + Style::default().fg(app.theme.gray()), + )]), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ); f.render_widget(title, chunks[0]); if app.entry_disp.is_empty() { - let empty = if app.active_tf.is_some() || !app.search_query.is_empty() { - "[ No matching entries found ]" + let empty_area = centered_rect(60, 20, chunks[1]); + let empty_msg = if app.active_tf.is_some() || !app.search_query.is_empty() { + "No matching entries found" } else { - "[ No passwords saved yet ]" + "No passwords saved yet" }; - let empty_paragraph = Paragraph::new(empty) - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(empty_paragraph, chunks[1]); + let empty = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + empty_msg, + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "Press '2' to add your first password", + Style::default().fg(app.theme.gray()), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ); + f.render_widget(empty, empty_area); } else { let content_area_top = chunks[1].y; let mut current_row = content_area_top; @@ -207,71 +244,108 @@ pub fn draw_view_pwds(f: &mut Frame, size: Rect, app: &mut App) { let list = List::new(items).block(Block::default().borders(Borders::NONE)); f.render_widget(list, chunks[1]); } - let help = Paragraph::new("| F: Clear filter │ Esc: Back |") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[2]); } #[allow(clippy::too_many_lines, clippy::cast_sign_loss)] pub fn draw_add_pwd(f: &mut Frame, size: Rect, app: &App) { let area = centered_rect(80, 85, size); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.green())) + .title(Span::styled( + " [ Add New Password ] ", + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.bg0())); + f.render_widget(block, area); + let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) .constraints([ Constraint::Length(2), // 0: title Constraint::Length(1), // 1: spacer - Constraint::Length(2), // 2: name - Constraint::Length(2), // 3: username - Constraint::Length(2), // 4: pwd + Constraint::Length(3), // 2: name + Constraint::Length(3), // 3: username + Constraint::Length(3), // 4: pwd Constraint::Length(2), // 5: strength bar - Constraint::Length(2), // 6: strength feedbkac - Constraint::Length(2), // 7: url - Constraint::Length(2), // 8: totp - Constraint::Length(2), // 9: tags input + Constraint::Length(2), // 6: strength feedback + Constraint::Length(3), // 7: url + Constraint::Length(3), // 8: totp + Constraint::Length(3), // 9: tags input Constraint::Length(3), // 10: Tags display - Constraint::Length(4), // 11: notes - Constraint::Min(1), // 12: msg - Constraint::Length(2), // 13: help + Constraint::Min(4), // 11: notes + Constraint::Length(3), // 12: msg ]) .split(area); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.green())) - .title("═══ ADD NEW PASSWORD ═══") - .title_alignment(Alignment::Center) - .style(Style::default().bg(app.theme.bg0())); - f.render_widget(block, area); + let title = Paragraph::new("Fill in the details below") .style(Style::default().fg(app.theme.yellow())) .alignment(Alignment::Center); f.render_widget(title, chunks[0]); + let active_style = Style::default() .fg(app.theme.green()) .add_modifier(Modifier::BOLD); let inactive_style = Style::default().fg(app.theme.gray()); - let name_field = - Paragraph::new(format!("Name: {}", app.n_entry_name)).style(if app.add_fi == 0 { + + let name_field = Paragraph::new(format!("Name: {}", app.n_entry_name)) + .style(if app.add_fi == 0 { active_style } else { inactive_style - }); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 0 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(name_field, chunks[2]); - let user_field = - Paragraph::new(format!("Username: {}", app.n_entry_user)).style(if app.add_fi == 1 { + + let user_field = Paragraph::new(format!("Username: {}", app.n_entry_user)) + .style(if app.add_fi == 1 { active_style } else { inactive_style - }); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 1 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(user_field, chunks[3]); - let pass_field = - Paragraph::new(format!("Password: {}", app.n_entry_pass)).style(if app.add_fi == 2 { + + let pass_field = Paragraph::new(format!("Password: {}", app.n_entry_pass)) + .style(if app.add_fi == 2 { active_style } else { inactive_style - }); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 2 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(pass_field, chunks[4]); + if !app.n_entry_pass.is_empty() && app.add_fi == 2 { let strength = crypto::calc_pwd_strength(&app.n_entry_pass); let strength_color = match strength.strength.as_str() { @@ -294,6 +368,7 @@ pub fn draw_add_pwd(f: &mut Frame, size: Rect, app: &App) { .style(Style::default().fg(strength_color)) .alignment(Alignment::Center); f.render_widget(strength_display, chunks[5]); + if !strength.feedback.is_empty() { let feedback_text = format!("↳ {}", strength.feedback.join(", ")); let feedback = Paragraph::new(feedback_text) @@ -303,21 +378,41 @@ pub fn draw_add_pwd(f: &mut Frame, size: Rect, app: &App) { f.render_widget(feedback, chunks[6]); } } - let url_field = - Paragraph::new(format!("URL (optional): {}", app.n_entry_url)).style(if app.add_fi == 3 { + + let url_field = Paragraph::new(format!("URL (optional): {}", app.n_entry_url)) + .style(if app.add_fi == 3 { active_style } else { inactive_style - }); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 3 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(url_field, chunks[7]); - let totp_field = Paragraph::new(format!("2FA Secret (optional): {}", app.n_entry_totp)).style( - if app.add_fi == 4 { + let totp_field = Paragraph::new(format!("2FA Secret (optional): {}", app.n_entry_totp)) + .style(if app.add_fi == 4 { active_style } else { inactive_style - }, - ); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 4 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(totp_field, chunks[8]); let tags_text = if app.add_fi == 5 { @@ -331,8 +426,19 @@ pub fn draw_add_pwd(f: &mut Frame, size: Rect, app: &App) { } else { inactive_style }) - .wrap(Wrap { trim: true }); + .wrap(Wrap { trim: true }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 5 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(tags_input, chunks[9]); + if !app.n_entry_tags.is_empty() { let tags_display = app .n_entry_tags @@ -346,14 +452,36 @@ pub fn draw_add_pwd(f: &mut Frame, size: Rect, app: &App) { .wrap(Wrap { trim: true }); f.render_widget(tags_widget, chunks[10]); } - let notes = Paragraph::new(format!("Notes:\n{}", app.n_entry_notes)) - .style(if app.add_fi == 5 { + + let notes_lines: Vec = if app.n_entry_notes.is_empty() { + vec![Line::from("Notes:")] + } else { + let mut lines = vec![Line::from("Notes:")]; + for line in app.n_entry_notes.lines() { + lines.push(Line::from(line.to_string())); + } + lines + }; + + let notes = Paragraph::new(notes_lines) + .style(if app.add_fi == 6 { active_style } else { inactive_style }) - .wrap(Wrap { trim: false }); + .wrap(Wrap { trim: false }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 6 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(notes, chunks[11]); + if !app.msg.is_empty() { let msg_style = match app.msg_type { MessageType::Success => Style::default().fg(app.theme.green()), @@ -366,72 +494,108 @@ pub fn draw_add_pwd(f: &mut Frame, size: Rect, app: &App) { .alignment(Alignment::Center); f.render_widget(msg, chunks[12]); } - let help = - Paragraph::new("| Tab: Next field │ Enter: Add tag/Save │ 1-9: Remove tag │ Esc: Cancel |") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[13]); } #[allow(clippy::too_many_lines, clippy::cast_sign_loss)] pub fn draw_edit_pwd(f: &mut Frame, size: Rect, app: &App) { let area = centered_rect(80, 85, size); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.orange())) + .title(Span::styled( + " [ Edit Password ] ", + Style::default() + .fg(app.theme.orange()) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.bg0())); + f.render_widget(block, area); + let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) .constraints([ Constraint::Length(2), // 0: title Constraint::Length(1), // 1: spacer - Constraint::Length(2), // 2: name - Constraint::Length(2), // 3: username - Constraint::Length(2), // 4: pwd + Constraint::Length(3), // 2: name + Constraint::Length(3), // 3: username + Constraint::Length(3), // 4: pwd Constraint::Length(2), // 5: strength bar Constraint::Length(2), // 6: strength feedback - Constraint::Length(2), // 7: url - Constraint::Length(2), // 8: totp - Constraint::Length(2), // 9: tags input + Constraint::Length(3), // 7: url + Constraint::Length(3), // 8: totp + Constraint::Length(3), // 9: tags input Constraint::Length(3), // 10: tags display - Constraint::Length(4), // 11: notes - Constraint::Min(1), // 12: msg - Constraint::Length(2), // 13: help + Constraint::Min(4), // 11: notes + Constraint::Length(3), // 12: msg ]) .split(area); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.orange())) - .title("═══ EDIT PASSWORD ═══") - .title_alignment(Alignment::Center) - .style(Style::default().bg(app.theme.bg0())); - f.render_widget(block, area); + let title = Paragraph::new("Edit entry details (password changes are tracked)") .style(Style::default().fg(app.theme.yellow())) .alignment(Alignment::Center); f.render_widget(title, chunks[0]); + let active_style = Style::default() .fg(app.theme.green()) .add_modifier(Modifier::BOLD); let inactive_style = Style::default().fg(app.theme.gray()); - let name_field = - Paragraph::new(format!("Name: {}", app.n_entry_name)).style(if app.add_fi == 0 { + + let name_field = Paragraph::new(format!("Name: {}", app.n_entry_name)) + .style(if app.add_fi == 0 { active_style } else { inactive_style - }); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 0 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(name_field, chunks[2]); - let user_field = - Paragraph::new(format!("Username: {}", app.n_entry_user)).style(if app.add_fi == 1 { + + let user_field = Paragraph::new(format!("Username: {}", app.n_entry_user)) + .style(if app.add_fi == 1 { active_style } else { inactive_style - }); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 1 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(user_field, chunks[3]); - let pass_field = - Paragraph::new(format!("Password: {}", app.n_entry_pass)).style(if app.add_fi == 2 { + + let pass_field = Paragraph::new(format!("Password: {}", app.n_entry_pass)) + .style(if app.add_fi == 2 { active_style } else { inactive_style - }); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 2 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(pass_field, chunks[4]); + if !app.n_entry_pass.is_empty() && app.add_fi == 2 { let strength = crypto::calc_pwd_strength(&app.n_entry_pass); let strength_color = match strength.strength.as_str() { @@ -454,6 +618,7 @@ pub fn draw_edit_pwd(f: &mut Frame, size: Rect, app: &App) { .style(Style::default().fg(strength_color)) .alignment(Alignment::Center); f.render_widget(strength_display, chunks[5]); + if !strength.feedback.is_empty() { let feedback_text = format!("↳ {}", strength.feedback.join(", ")); let feedback = Paragraph::new(feedback_text) @@ -463,20 +628,41 @@ pub fn draw_edit_pwd(f: &mut Frame, size: Rect, app: &App) { f.render_widget(feedback, chunks[6]); } } - let url_field = Paragraph::new(format!("URL: {}", app.n_entry_url)).style(if app.add_fi == 3 { - active_style - } else { - inactive_style - }); + + let url_field = Paragraph::new(format!("URL: {}", app.n_entry_url)) + .style(if app.add_fi == 3 { + active_style + } else { + inactive_style + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 3 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(url_field, chunks[7]); - let totp_field = Paragraph::new(format!("2FA Secret (optional): {}", app.n_entry_totp)).style( - if app.add_fi == 4 { + let totp_field = Paragraph::new(format!("2FA Secret (optional): {}", app.n_entry_totp)) + .style(if app.add_fi == 4 { active_style } else { inactive_style - }, - ); + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 4 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(totp_field, chunks[8]); let tags_text = if app.add_fi == 5 { @@ -490,8 +676,19 @@ pub fn draw_edit_pwd(f: &mut Frame, size: Rect, app: &App) { } else { inactive_style }) - .wrap(Wrap { trim: true }); + .wrap(Wrap { trim: true }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 5 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(tags_input, chunks[9]); + if !app.n_entry_tags.is_empty() { let tags_display = app .n_entry_tags @@ -505,14 +702,36 @@ pub fn draw_edit_pwd(f: &mut Frame, size: Rect, app: &App) { .wrap(Wrap { trim: true }); f.render_widget(tags_widget, chunks[10]); } - let notes = Paragraph::new(format!("Notes:\n{}", app.n_entry_notes)) + + let notes_lines: Vec = if app.n_entry_notes.is_empty() { + vec![Line::from("Notes:")] + } else { + let mut lines = vec![Line::from("Notes:")]; + for line in app.n_entry_notes.lines() { + lines.push(Line::from(line.to_string())); + } + lines + }; + + let notes = Paragraph::new(notes_lines) .style(if app.add_fi == 6 { active_style } else { inactive_style }) - .wrap(Wrap { trim: false }); + .wrap(Wrap { trim: false }) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if app.add_fi == 6 { + app.theme.green() + } else { + app.theme.gray() + })), + ); f.render_widget(notes, chunks[11]); + if !app.msg.is_empty() { let msg_style = match app.msg_type { MessageType::Success => Style::default().fg(app.theme.green()), @@ -525,44 +744,73 @@ pub fn draw_edit_pwd(f: &mut Frame, size: Rect, app: &App) { .alignment(Alignment::Center); f.render_widget(msg, chunks[12]); } - let help = - Paragraph::new("| Tab: Next │ Enter: Add tag/Save │ 1-9: Remove tag │ Esc: Cancel |") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[13]); } pub fn draw_history(f: &mut Frame, size: Rect, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(size); let block = Block::default() .borders(Borders::ALL) + .border_type(BorderType::Rounded) .border_style(Style::default().fg(app.theme.purple())) - .title("═══ PASSWORD HISTORY ═══") - .title_alignment(Alignment::Center) + .title(Span::styled( + " [ Password History ] ", + Style::default() + .fg(app.theme.purple()) + .add_modifier(Modifier::BOLD), + )) .style(Style::default().bg(app.theme.bg0())); f.render_widget(block, size); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Length(3), Constraint::Min(5)]) + .split(size); + if let Some(ref vault) = app.vault { if app.selected_entry < app.entry_disp.len() { let entry = &app.entry_disp[app.selected_entry]; if let Some(vault_entry) = vault.e.iter().find(|e| e.id == entry.id) { - let title = - Paragraph::new(format!("History for: {} (Last 5 changes)", vault_entry.n)) - .style(Style::default().fg(app.theme.yellow())) - .alignment(Alignment::Center); + let title = Paragraph::new(vec![ + Line::from(vec![Span::styled( + format!("History for: {}", vault_entry.n), + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![Span::styled( + "Last 5 changes", + Style::default().fg(app.theme.gray()), + )]), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ); f.render_widget(title, chunks[0]); + if vault_entry.history.is_empty() { - let empty = Paragraph::new("[ No password changes recorded ]") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(empty, chunks[1]); + let empty_area = centered_rect(60, 20, chunks[1]); + let empty = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "No password changes recorded", + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ); + f.render_widget(empty, empty_area); } else { let items: Vec = vault_entry .history @@ -600,72 +848,103 @@ pub fn draw_history(f: &mut Frame, size: Rect, app: &App) { } } } - let help = Paragraph::new("| Esc: Back |") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[2]); } pub fn draw_del_pwd(f: &mut Frame, size: Rect, app: &App) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.red())) + .title(Span::styled( + " [ Delete Password ] ", + Style::default() + .fg(app.theme.red()) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.bg0())); + f.render_widget(block, size); + let chunks = Layout::default() .direction(Direction::Vertical) - .margin(1) + .margin(2) .constraints([ Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), - Constraint::Length(3), ]) .split(size); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.red())) - .title("═══ DELETE PASSWORD ═══") - .title_alignment(Alignment::Center) - .style(Style::default().bg(app.theme.bg0())); - f.render_widget(block, size); + let title = Paragraph::new("⚠ Enter the number of the entry to delete") .style(Style::default().fg(app.theme.orange())) - .alignment(Alignment::Center); + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.orange())), + ); f.render_widget(title, chunks[0]); + let empty_vec = Vec::new(); let entries_to_display = if app.entry_disp.is_empty() { app.vault.as_ref().map_or(&empty_vec, |v| &v.e) } else { &app.entry_disp }; + if entries_to_display.is_empty() { - let empty = Paragraph::new("[ No passwords to delete ]") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(empty, chunks[1]); + let empty_area = centered_rect(60, 20, chunks[1]); + let empty = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "No passwords to delete", + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ); + f.render_widget(empty, empty_area); } else { let items: Vec = entries_to_display .iter() .enumerate() .map(|(i, entry)| { - ListItem::new(Line::from(vec![ - Span::styled( - format!("[{}] ", i + 1), - Style::default().fg(app.theme.red()), - ), - Span::styled(&entry.n, Style::default().fg(app.theme.fg())), - ])) + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + format!("[{}] ", i + 1), + Style::default().fg(app.theme.red()), + ), + Span::styled(&entry.n, Style::default().fg(app.theme.fg())), + ]), + Line::from(""), + ]) }) .collect(); let list = List::new(items).block(Block::default().borders(Borders::NONE)); f.render_widget(list, chunks[1]); } - let input = Paragraph::new(format!("Entry number: {}", app.input_buffer)).style( - Style::default() - .fg(app.theme.red()) - .add_modifier(Modifier::BOLD), - ); + + let input = Paragraph::new(format!("Entry number: {}", app.input_buffer)) + .style( + Style::default() + .fg(app.theme.red()) + .add_modifier(Modifier::BOLD), + ) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.red())), + ); f.render_widget(input, chunks[2]); - let help = Paragraph::new("Type number │ Enter: Delete │ Esc: Cancel") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[3]); } pub fn draw_context_menu(f: &mut Frame, app: &App) { @@ -728,6 +1007,7 @@ pub fn draw_context_menu(f: &mut Frame, app: &App) { let block = Block::default() .borders(Borders::ALL) + .border_type(BorderType::Rounded) .border_style(Style::default().fg(orange)) .style(Style::default().bg(bg1)); diff --git a/src/ui/widgets/utility.rs b/src/ui/widgets/utility.rs index 11280e0..74bb86a 100644 --- a/src/ui/widgets/utility.rs +++ b/src/ui/widgets/utility.rs @@ -27,39 +27,124 @@ pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { .split(popup_layout[1])[1] } +#[allow(clippy::too_many_lines)] pub fn draw_search_pwd(f: &mut Frame, size: Rect, app: &App) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.blue())) + .title(Span::styled( + " [ Search Passwords ] ", + Style::default() + .fg(app.theme.blue()) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.bg0())); + f.render_widget(block, size); + let chunks = Layout::default() .direction(Direction::Vertical) - .margin(1) + .margin(2) .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), + Constraint::Length(3), // search bar + Constraint::Length(1), // spacer + Constraint::Min(5), // results ]) .split(size); - let block = Block::default() + + let search_area = chunks[0]; + let search_block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.blue())) - .title("═══ SEARCH PASSWORDS ═══") - .title_alignment(Alignment::Center) - .style(Style::default().bg(app.theme.bg0())); - f.render_widget(block, size); - let title = Paragraph::new("Search by name, username, URL, or tags") - .style(Style::default().fg(app.theme.yellow())) - .alignment(Alignment::Center); - f.render_widget(title, chunks[0]); - let search = Paragraph::new(format!("Search: {}", app.search_query)).style( - Style::default() - .fg(app.theme.green()) - .add_modifier(Modifier::BOLD), - ); - f.render_widget(search, chunks[1]); - if app.entry_disp.is_empty() && !app.search_query.is_empty() { - let empty = Paragraph::new("[ No matches found ]") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(empty, chunks[2]); + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(if app.search_query.is_empty() { + app.theme.gray() + } else { + app.theme.green() + })) + .title(Span::styled( + " Search ", + Style::default().fg(app.theme.yellow()), + )); + + let search_text = if app.search_query.is_empty() { + Span::styled("Type to search...", Style::default().fg(app.theme.gray())) + } else { + Span::styled( + &app.search_query, + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + ) + }; + + let search_widget = Paragraph::new(search_text) + .block(search_block) + .alignment(Alignment::Left); + f.render_widget(search_widget, search_area); + + if app.search_query.is_empty() { + let tips_area = centered_rect(70, 30, chunks[2]); + let tips = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "Quick Search Tips", + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("• ", Style::default().fg(app.theme.orange())), + Span::raw("Search by "), + Span::styled( + "name, username, URL, or tags", + Style::default().fg(app.theme.green()), + ), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(app.theme.orange())), + Span::raw("Results update "), + Span::styled("as you type", Style::default().fg(app.theme.blue())), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(app.theme.orange())), + Span::raw("Press "), + Span::styled("Enter", Style::default().fg(app.theme.purple())), + Span::raw(" to view detailed results"), + ]), + ]) + .alignment(Alignment::Left) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.aqua())), + ); + f.render_widget(tips, tips_area); + } else if app.entry_disp.is_empty() { + let empty_area = centered_rect(60, 20, chunks[2]); + let empty = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "No matches found", + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "Try a different search term", + Style::default().fg(app.theme.gray()), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ); + f.render_widget(empty, empty_area); } else if !app.entry_disp.is_empty() { let items: Vec = app .entry_disp @@ -67,7 +152,7 @@ pub fn draw_search_pwd(f: &mut Frame, size: Rect, app: &App) { .map(|entry| { let mut lines = vec![ Line::from(vec![ - Span::styled("• ", Style::default().fg(app.theme.orange())), + Span::styled("▶ ", Style::default().fg(app.theme.orange())), Span::styled( &entry.n, Style::default() @@ -76,34 +161,86 @@ pub fn draw_search_pwd(f: &mut Frame, size: Rect, app: &App) { ), ]), Line::from(vec![ - Span::styled(" User: ", Style::default().fg(app.theme.gray())), + Span::styled(" Username: ", Style::default().fg(app.theme.gray())), Span::styled(&entry.u, Style::default().fg(app.theme.blue())), ]), - Line::from(vec![ - Span::styled(" Pass: ", Style::default().fg(app.theme.gray())), - Span::styled(&entry.p, Style::default().fg(app.theme.green())), - ]), ]; + + if let Some(ref url) = entry.url { + lines.push(Line::from(vec![ + Span::styled(" URL: ", Style::default().fg(app.theme.gray())), + Span::styled(url, Style::default().fg(app.theme.aqua())), + ])); + } + if !entry.tags.is_empty() { lines.push(Line::from(vec![ Span::styled(" Tags: ", Style::default().fg(app.theme.gray())), Span::styled( entry.tags.join(", "), - Style::default().fg(app.theme.orange()), + Style::default().fg(app.theme.purple()), ), ])); } + lines.push(Line::from("")); ListItem::new(lines) }) .collect(); - let list = List::new(items).block(Block::default().borders(Borders::NONE)); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.green())) + .title(Span::styled( + format!(" [ {} Results ] ", app.entry_disp.len()), + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + )), + ); f.render_widget(list, chunks[2]); + } else { + let tips_area = centered_rect(70, 30, chunks[2]); + let tips = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "Quick Search Tips", + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("• ", Style::default().fg(app.theme.orange())), + Span::raw("Search by "), + Span::styled( + "name, username, URL, or tags", + Style::default().fg(app.theme.green()), + ), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(app.theme.orange())), + Span::raw("Results update "), + Span::styled("as you type", Style::default().fg(app.theme.blue())), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(app.theme.orange())), + Span::raw("Press "), + Span::styled("Enter", Style::default().fg(app.theme.purple())), + Span::raw(" to view detailed results"), + ]), + ]) + .alignment(Alignment::Left) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.aqua())), + ); + f.render_widget(tips, tips_area); } - let help = Paragraph::new("Type to search │ Enter: View results │ Esc: Back") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[3]); } pub fn draw_gen_pwd(f: &mut Frame, size: Rect, app: &App) { @@ -112,24 +249,32 @@ pub fn draw_gen_pwd(f: &mut Frame, size: Rect, app: &App) { .direction(Direction::Vertical) .margin(2) .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(6), - Constraint::Min(1), - Constraint::Length(3), + Constraint::Length(3), // title + Constraint::Length(1), // spacer + Constraint::Length(3), // length input + Constraint::Min(5), // gen pwd area + Constraint::Length(1), // spacer ]) .split(area); + let block = Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(app.theme.aqua())) - .title("═══ GENERATE PASSWORD ═══") - .title_alignment(Alignment::Center) + .title(Span::styled( + " [ Generate Password ] ", + Style::default() + .fg(app.theme.aqua()) + .add_modifier(Modifier::BOLD), + )) .style(Style::default().bg(app.theme.bg0())); f.render_widget(block, area); + let title = Paragraph::new("Enter password length (4-64)") .style(Style::default().fg(app.theme.yellow())) .alignment(Alignment::Center); f.render_widget(title, chunks[0]); + let length_input = Paragraph::new(format!( "Length: {}", if app.input_buffer.is_empty() { @@ -142,8 +287,16 @@ pub fn draw_gen_pwd(f: &mut Frame, size: Rect, app: &App) { Style::default() .fg(app.theme.green()) .add_modifier(Modifier::BOLD), - ); - f.render_widget(length_input, chunks[1]); + ) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ) + .alignment(Alignment::Center); + f.render_widget(length_input, chunks[2]); + if !app.gen_pwd.is_empty() { let generated = Paragraph::new(vec![ Line::from(""), @@ -159,97 +312,180 @@ pub fn draw_gen_pwd(f: &mut Frame, size: Rect, app: &App) { .add_modifier(Modifier::BOLD), )), ]) - .alignment(Alignment::Center); - f.render_widget(generated, chunks[2]); + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.green())), + ); + f.render_widget(generated, chunks[3]); } - let help = Paragraph::new("| Enter: Generate │ Esc: Back |") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[4]); } +#[allow(clippy::too_many_lines)] pub fn draw_filter_tags(f: &mut Frame, size: Rect, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - Constraint::Length(3), - ]) - .split(size); let block = Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(app.theme.purple())) - .title("═══ FILTER BY TAG ═══") - .title_alignment(Alignment::Center) + .title(Span::styled( + " [ Filter by Tags ] ", + Style::default() + .fg(app.theme.purple()) + .add_modifier(Modifier::BOLD), + )) .style(Style::default().bg(app.theme.bg0())); f.render_widget(block, size); - let title = if let Some(ref tag) = app.active_tf { - format!("Filtering by: [{}] ({} entries)", tag, app.entry_disp.len()) - } else { - "Select a tag to filter".to_string() - }; - let title_widget = Paragraph::new(title) - .style(Style::default().fg(app.theme.yellow())) - .alignment(Alignment::Center); - f.render_widget(title_widget, chunks[0]); - if app.all_tags.is_empty() { - let empty = Paragraph::new("[ No tags available - Add tags to your passwords first ]") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(empty, chunks[1]); - } else { - let mut items = vec![ListItem::new(Line::from(vec![ + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // status or active filter + Constraint::Length(1), // spacer + Constraint::Min(5), // tags filter + ]) + .split(size); + + if let Some(ref tag) = app.active_tf { + let status = Paragraph::new(vec![Line::from(vec![ + Span::styled("Active Filter: ", Style::default().fg(app.theme.gray())), Span::styled( - if app.select_tf == 0 { "▶ " } else { " " }, - Style::default().fg(app.theme.yellow()), + tag, + Style::default() + .fg(app.theme.purple()) + .add_modifier(Modifier::BOLD), ), Span::styled( - format!( - "All ({} total)", - app.vault.as_ref().map_or(0, |v| v.e.len()) - ), - if app.select_tf == 0 { - Style::default() - .fg(app.theme.green()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.theme.fg()) - }, + format!(" ({} entries)", app.entry_disp.len()), + Style::default().fg(app.theme.aqua()), ), - ]))]; + ])]) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.green())), + ) + .alignment(Alignment::Center); + f.render_widget(status, chunks[0]); + } else { + let status = Paragraph::new("No active filter") + .style(Style::default().fg(app.theme.gray())) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ) + .alignment(Alignment::Center); + f.render_widget(status, chunks[0]); + } + + if app.all_tags.is_empty() { + let empty_area = centered_rect(60, 30, chunks[2]); + let empty = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "No tags available", + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "Add tags to your passwords first", + Style::default().fg(app.theme.gray()), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.gray())), + ); + f.render_widget(empty, empty_area); + } else { + let mut items = vec![ + ListItem::new(vec![Line::from("")]), + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + if app.select_tf == 0 { "▶ " } else { " " }, + Style::default().fg(app.theme.yellow()), + ), + Span::styled( + "[ALL]", + if app.select_tf == 0 { + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.theme.fg()) + }, + ), + Span::raw(" "), + Span::styled( + format!("({} total)", app.vault.as_ref().map_or(0, |v| v.e.len())), + Style::default().fg(app.theme.gray()), + ), + ]), + Line::from(""), + ]), + ]; + for (idx, (tag, count)) in app.all_tags.iter().enumerate() { let is_selected = idx + 1 == app.select_tf; + let is_active = app.active_tf.as_ref() == Some(tag); + let prefix = if is_selected { "▶ " } else { " " }; - items.push(ListItem::new(Line::from(vec![ - Span::styled(prefix, Style::default().fg(app.theme.yellow())), - Span::styled( - format!("[{tag}] ({count} entries)"), - if is_selected { - Style::default() - .fg(app.theme.orange()) - .add_modifier(Modifier::BOLD) + let tag_style = if is_selected { + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD) + } else if is_active { + Style::default() + .fg(app.theme.purple()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.theme.fg()) + }; + + items.push(ListItem::new(vec![ + Line::from(vec![ + Span::styled(prefix, Style::default().fg(app.theme.yellow())), + Span::styled(format!("[{tag}]"), tag_style), + Span::raw(" "), + Span::styled( + format!("({count} entries)"), + Style::default().fg(app.theme.gray()), + ), + if is_active { + Span::styled(" ✓", Style::default().fg(app.theme.green())) } else { - Style::default().fg(app.theme.fg()) + Span::raw("") }, - ), - ]))); + ]), + Line::from(""), + ])); } - let list = List::new(items).block(Block::default().borders(Borders::NONE)); - f.render_widget(list, chunks[1]); - } - if app.active_tf.is_some() { - let filter_info = Paragraph::new("Press V to view filtered passwords") - .style(Style::default().fg(app.theme.aqua())) - .alignment(Alignment::Center); - f.render_widget(filter_info, chunks[2]); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(app.theme.purple())) + .title(Span::styled( + format!(" [ {} Tags Available ] ", app.all_tags.len()), + Style::default() + .fg(app.theme.aqua()) + .add_modifier(Modifier::BOLD), + )), + ); + f.render_widget(list, chunks[2]); } - let help = Paragraph::new("Enter: Apply │ V: View │ Esc: Back") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[3]); } #[allow(clippy::too_many_lines)] @@ -259,11 +495,7 @@ pub fn draw_theme_selector(f: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) - .constraints([ - Constraint::Length(3), - Constraint::Min(10), - Constraint::Length(3), - ]) + .constraints([Constraint::Length(3), Constraint::Min(10)]) .split(area); let title = Paragraph::new("THEME SELECTOR") @@ -276,6 +508,7 @@ pub fn draw_theme_selector(f: &mut Frame, area: Rect, app: &App) { .block( Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(app.theme.aqua())), ); f.render_widget(title, chunks[0]); @@ -324,8 +557,14 @@ pub fn draw_theme_selector(f: &mut Frame, area: Rect, app: &App) { let list = List::new(items).block( Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(app.theme.blue())) - .title(" Themes "), + .title(Span::styled( + " Themes ", + Style::default() + .fg(app.theme.blue()) + .add_modifier(Modifier::BOLD), + )), ); f.render_widget(list, content_chunks[0]); @@ -343,30 +582,33 @@ pub fn draw_theme_selector(f: &mut Frame, area: Rect, app: &App) { Line::from(""), Line::from(vec![Span::styled( "Colors:", - Style::default().fg(app.theme.gray()), + Style::default() + .fg(app.theme.gray()) + .add_modifier(Modifier::BOLD), )]), + Line::from(""), Line::from(vec![ - Span::raw(" Red: "), + Span::raw(" Red "), Span::styled("███", Style::default().fg(preview_theme.red())), ]), Line::from(vec![ - Span::raw(" Green: "), + Span::raw(" Green "), Span::styled("███", Style::default().fg(preview_theme.green())), ]), Line::from(vec![ - Span::raw(" Yellow: "), + Span::raw(" Yellow "), Span::styled("███", Style::default().fg(preview_theme.yellow())), ]), Line::from(vec![ - Span::raw(" Blue: "), + Span::raw(" Blue "), Span::styled("███", Style::default().fg(preview_theme.blue())), ]), Line::from(vec![ - Span::raw(" Purple: "), + Span::raw(" Purple "), Span::styled("███", Style::default().fg(preview_theme.purple())), ]), Line::from(vec![ - Span::raw(" Aqua: "), + Span::raw(" Aqua "), Span::styled("███", Style::default().fg(preview_theme.aqua())), ]), ]; @@ -375,28 +617,17 @@ pub fn draw_theme_selector(f: &mut Frame, area: Rect, app: &App) { .block( Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(app.theme.purple())) - .title(" Preview "), + .title(Span::styled( + " Preview ", + Style::default() + .fg(app.theme.purple()) + .add_modifier(Modifier::BOLD), + )), ) .alignment(Alignment::Left); f.render_widget(preview, content_chunks[1]); - - let help_text = vec![ - Span::styled("Enter", Style::default().fg(app.theme.green())), - Span::raw(": Apply "), - Span::styled("Esc", Style::default().fg(app.theme.red())), - Span::raw(": Cancel"), - ]; - - let help = Paragraph::new(Line::from(help_text)) - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.gray())), - ); - f.render_widget(help, chunks[2]); } #[allow(clippy::too_many_lines)] @@ -462,7 +693,7 @@ pub fn draw_options_menu(f: &mut Frame, area: Rect, app: &App) { .add_modifier(Modifier::BOLD), )]), Line::from(vec![Span::styled( - " v2.3.5", + " v2.3.4", Style::default().fg(app.theme.gray()), )]), ]; @@ -538,9 +769,14 @@ pub fn draw_settings_screen(f: &mut Frame, area: Rect, app: &App) { let block = Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(app.theme.blue())) - .title("═══ SETTINGS ═══") - .title_alignment(Alignment::Center) + .title(Span::styled( + " [ Settings ] ", + Style::default() + .fg(app.theme.blue()) + .add_modifier(Modifier::BOLD), + )) .style(Style::default().bg(app.theme.bg0())); let inner = block.inner(centered_area); @@ -549,11 +785,7 @@ pub fn draw_settings_screen(f: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - ]) + .constraints([Constraint::Length(3), Constraint::Min(5)]) .split(inner); let title = Paragraph::new("Preferences") @@ -566,7 +798,7 @@ pub fn draw_settings_screen(f: &mut Frame, area: Rect, app: &App) { f.render_widget(title, chunks[0]); let current_theme = app.theme.name(); - let cltime_disp = if app.clipboard_timeout == 0 { + let clipboard_timeout_display = if app.clipboard_timeout == 0 { "Never".to_string() } else { format!("{}s", app.clipboard_timeout) @@ -574,8 +806,12 @@ pub fn draw_settings_screen(f: &mut Frame, area: Rect, app: &App) { let items = [ ("1", "Theme Selector", format!("Current: {current_theme}")), - ("2", "Auto-Save", "Coming soon".to_string()), - ("3", "Clipboard Timeout", format!("Current: {cltime_disp}")), + ( + "2", + "Clipboard Timeout", + format!("Current: {clipboard_timeout_display}"), + ), + ("3", "Auto-Save", "Coming soon".to_string()), ]; let list_items: Vec = items @@ -620,9 +856,14 @@ pub fn draw_help_screen(f: &mut Frame, area: Rect, app: &App) { let block = Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(app.theme.green())) - .title("═══ PASSLOCK HELP ═══") - .title_alignment(Alignment::Center) + .title(Span::styled( + " [ PassLock Help ] ", + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + )) .style(Style::default().bg(app.theme.bg0())); let inner = block.inner(area); @@ -688,6 +929,10 @@ pub fn draw_help_screen(f: &mut Frame, area: Rect, app: &App) { Span::styled(" F ", Style::default().fg(app.theme.orange())), Span::raw("Clear filters"), ]), + Line::from(vec![ + Span::styled(" V ", Style::default().fg(app.theme.orange())), + Span::raw("View filtered passwords (when in Filter Tags screen)"), + ]), Line::from(vec![ Span::styled(" Esc ", Style::default().fg(app.theme.orange())), Span::raw("Back to main menu"), diff --git a/src/ui/widgets/vault.rs b/src/ui/widgets/vault.rs index 119e3fa..2cd3547 100644 --- a/src/ui/widgets/vault.rs +++ b/src/ui/widgets/vault.rs @@ -6,7 +6,8 @@ use crate::crypto; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, Frame, }; @@ -14,51 +15,83 @@ pub fn draw_loading(f: &mut Frame, size: Rect, app: &App) { let area = centered_rect(50, 30, size); let block = Block::default() .borders(Borders::ALL) + .border_type(BorderType::Rounded) .border_style(Style::default().fg(app.theme.yellow())) - .title(" PASSLOCK ") - .title_alignment(Alignment::Center) + .title(Span::styled( + " [ PassLock ] ", + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + )) .style(Style::default().bg(app.theme.bg0())); - let text = Paragraph::new("Initializing vault...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().fg(app.theme.fg())); + + let text = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "Initializing vault...", + Style::default() + .fg(app.theme.aqua()) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "Please wait", + Style::default().fg(app.theme.gray()), + )), + ]) + .block(block) + .alignment(Alignment::Center); + f.render_widget(Clear, area); f.render_widget(text, area); } -#[allow(clippy::cast_sign_loss)] +#[allow(clippy::cast_sign_loss, clippy::too_many_lines)] pub fn draw_create_vault(f: &mut Frame, size: Rect, app: &App) { let area = centered_rect(65, 70, size); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.orange())) + .title(Span::styled( + " [ Create Vault ] ", + Style::default() + .fg(app.theme.orange()) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.bg0())); + f.render_widget(block, area); + let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) .constraints([ Constraint::Length(3), + Constraint::Length(2), Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(2), Constraint::Length(1), - Constraint::Length(3), Constraint::Min(2), - Constraint::Length(3), ]) .split(area); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.orange())) - .title("═══ CREATE VAULT ═══") - .title_alignment(Alignment::Center) - .style(Style::default().bg(app.theme.bg0())); - f.render_widget(block, area); - let title = Paragraph::new("Create your master password") - .style(Style::default().fg(app.theme.yellow())) + .style( + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center); f.render_widget(title, chunks[0]); - let pwd_text = format!("Password: {}", "•".repeat(app.input_buffer.len())); + let pwd_text = if app.input_buffer.is_empty() { + "Password: _".to_string() + } else { + format!("Password: {}", "•".repeat(app.input_buffer.len())) + }; let pwd_style = if app.input_field == InputField::Password { Style::default() .fg(app.theme.green()) @@ -66,8 +99,10 @@ pub fn draw_create_vault(f: &mut Frame, size: Rect, app: &App) { } else { Style::default().fg(app.theme.gray()) }; - let password_input = Paragraph::new(pwd_text).style(pwd_style); - f.render_widget(password_input, chunks[2]); + let password_input = Paragraph::new(pwd_text) + .style(pwd_style) + .alignment(Alignment::Left); + f.render_widget(password_input, chunks[1]); if !app.input_buffer.is_empty() && app.input_field == InputField::Password { let strength = crypto::calc_pwd_strength(&app.input_buffer); @@ -102,7 +137,11 @@ pub fn draw_create_vault(f: &mut Frame, size: Rect, app: &App) { } } - let confirm_text = format!("Confirm: {}", "•".repeat(app.input_buffer2.len())); + let confirm_text = if app.input_buffer2.is_empty() { + "Confirm: _".to_string() + } else { + format!("Confirm: {}", "•".repeat(app.input_buffer2.len())) + }; let confirm_style = if app.input_field == InputField::PasswordConfirm { Style::default() .fg(app.theme.green()) @@ -110,7 +149,9 @@ pub fn draw_create_vault(f: &mut Frame, size: Rect, app: &App) { } else { Style::default().fg(app.theme.gray()) }; - let confirm_input = Paragraph::new(confirm_text).style(confirm_style); + let confirm_input = Paragraph::new(confirm_text) + .style(confirm_style) + .alignment(Alignment::Left); f.render_widget(confirm_input, chunks[6]); if !app.msg.is_empty() { @@ -125,47 +166,57 @@ pub fn draw_create_vault(f: &mut Frame, size: Rect, app: &App) { .alignment(Alignment::Center); f.render_widget(msg, chunks[7]); } - - let help = Paragraph::new("| Tab: Switch | Enter: Create | Esc: Quit |") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[8]); } pub fn draw_unlock_vault(f: &mut Frame, size: Rect, app: &App) { let area = centered_rect(60, 40, size); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(app.theme.aqua())) + .title(Span::styled( + " [ Unlock Vault ] ", + Style::default() + .fg(app.theme.aqua()) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.bg0())); + f.render_widget(block, area); + let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) .constraints([ Constraint::Length(3), + Constraint::Length(2), Constraint::Length(1), - Constraint::Length(3), Constraint::Min(2), - Constraint::Length(3), ]) .split(area); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.aqua())) - .title("═══ UNLOCK VAULT ═══") - .title_alignment(Alignment::Center) - .style(Style::default().bg(app.theme.bg0())); - f.render_widget(block, area); - let title = Paragraph::new("Enter your master password") - .style(Style::default().fg(app.theme.yellow())) + .style( + Style::default() + .fg(app.theme.yellow()) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center); f.render_widget(title, chunks[0]); - let pwd_text = format!("Password: {}", "•".repeat(app.input_buffer.len())); - let password_input = Paragraph::new(pwd_text).style( - Style::default() - .fg(app.theme.green()) - .add_modifier(Modifier::BOLD), - ); - f.render_widget(password_input, chunks[2]); + let pwd_text = if app.input_buffer.is_empty() { + "Password: _".to_string() + } else { + format!("Password: {}", "•".repeat(app.input_buffer.len())) + }; + let password_input = Paragraph::new(pwd_text) + .style( + Style::default() + .fg(app.theme.green()) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Left); + f.render_widget(password_input, chunks[1]); if !app.msg.is_empty() { let msg_style = match app.msg_type { @@ -179,9 +230,4 @@ pub fn draw_unlock_vault(f: &mut Frame, size: Rect, app: &App) { .alignment(Alignment::Center); f.render_widget(msg, chunks[3]); } - - let help = Paragraph::new("| Enter: Unlock | Esc: Quit |") - .style(Style::default().fg(app.theme.gray())) - .alignment(Alignment::Center); - f.render_widget(help, chunks[4]); }