diff --git a/src-tauri/crates/nyaterm-core/src/config/key.rs b/src-tauri/crates/nyaterm-core/src/config/key.rs index 957d10c9..0d63fe89 100644 --- a/src-tauri/crates/nyaterm-core/src/config/key.rs +++ b/src-tauri/crates/nyaterm-core/src/config/key.rs @@ -14,6 +14,8 @@ static SSH_KEY_ACCESS: RwLock<()> = RwLock::new(()); pub struct SshKey { #[serde(default = "uuid_v4")] pub id: String, + #[serde(default)] + pub sort_order: i32, pub name: String, /// Encrypted PEM content on disk. #[serde(default)] @@ -61,6 +63,7 @@ pub fn load_keys(app: &impl Sized) -> AppResult { let mut config = KeysConfig { keys: storage::list_ssh_keys()?, }; + sort_keys(&mut config.keys); for k in &mut config.keys { apply_key_status_flags(k); } @@ -118,13 +121,49 @@ pub fn decrypt_key_cert(key: &SshKey) -> AppResult> { crypto::decrypt_optional(&key.cert) } +pub fn sort_keys(entries: &mut [SshKey]) { + entries.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then(left.id.cmp(&right.id)) + }); +} + +/// Editing preserves position; new entries are appended to the saved order. +pub fn key_sort_order(config: &KeysConfig, id: &str) -> i32 { + config + .keys + .iter() + .find(|entry| entry.id == id) + .map(|entry| entry.sort_order) + .unwrap_or_else(|| { + config + .keys + .iter() + .map(|entry| entry.sort_order) + .max() + .unwrap_or(-1) + .saturating_add(1) + }) +} + +pub fn reorder_ssh_keys(config: &mut KeysConfig, updates: &[(String, i32)]) { + for (id, sort_order) in updates { + if let Some(entry) = config.keys.iter_mut().find(|entry| entry.id == *id) { + entry.sort_order = *sort_order; + } + } + sort_keys(&mut config.keys); +} + #[cfg(test)] mod tests { - use super::{SshKey, apply_key_status_flags}; + use super::*; #[test] fn key_status_flags_track_stored_key_and_certificate_data() { let mut key = SshKey { + sort_order: 0, id: "key-1".to_string(), name: "Key 1".to_string(), key: Some("encrypted-key".to_string()), @@ -149,4 +188,46 @@ mod tests { assert!(key.has_key_data); assert!(!key.has_cert_data); } + #[test] + fn key_sorting_defaults_preserves_secrets_and_appends() { + let legacy: SshKey = serde_json::from_value(serde_json::json!({"id":"b","name":"B","password":"cipher-password","key":"cipher-key","cert":"cipher-cert","passphrase":"cipher-passphrase"})).unwrap(); + assert_eq!(legacy.sort_order, 0); + let mut config: KeysConfig = serde_json::from_value(serde_json::json!({"keys":[ + {"id":"b","name":"B","password":"cipher-password","key":"cipher-key","cert":"cipher-cert","passphrase":"cipher-passphrase"}, + {"id":"a","name":"A"}, + {"id":"c","name":"C","sort_order":4} + ]})).unwrap(); + sort_keys(&mut config.keys); + assert_eq!( + config + .keys + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["a", "b", "c"] + ); + assert_eq!(key_sort_order(&config, "new"), 5); + assert_eq!(key_sort_order(&config, "b"), 0); + let before = serde_json::to_value(&config.keys[1]).unwrap(); + reorder_ssh_keys( + &mut config, + &[ + ("b".into(), 0), + ("c".into(), 1), + ("a".into(), 2), + ("missing".into(), 3), + ], + ); + assert_eq!( + config + .keys + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["b", "c", "a"] + ); + assert_eq!(serde_json::to_value(&config.keys[0]).unwrap(), before); + assert_eq!(key_sort_order(&config, "a"), 2); + assert_eq!(key_sort_order(&config, "new"), 3); + } } diff --git a/src-tauri/crates/nyaterm-core/src/config/mod.rs b/src-tauri/crates/nyaterm-core/src/config/mod.rs index c26be4b0..1c50a901 100644 --- a/src-tauri/crates/nyaterm-core/src/config/mod.rs +++ b/src-tauri/crates/nyaterm-core/src/config/mod.rs @@ -54,7 +54,8 @@ pub use credential::{ }; #[allow(unused_imports)] pub use key::{ - KeysConfig, SshKey, decrypt_key_cert, decrypt_key_pem, load_key_by_id, load_keys, save_keys, + KeysConfig, SshKey, decrypt_key_cert, decrypt_key_pem, key_sort_order, load_key_by_id, + load_keys, reorder_ssh_keys, save_keys, }; pub use key::{ssh_key_change_epoch, ssh_key_read_guard}; #[allow(unused_imports)] @@ -67,8 +68,8 @@ pub use otp::{OtpConfig, OtpEntry, load_otp_entries, load_otp_entry_by_id, save_ #[allow(unused_imports)] pub use password::{ PasswordsConfig, SavedPassword, decrypt_account_password, effective_account_id, - load_password_by_id, load_passwords, load_saved_account, resolve_account_username, - save_passwords, + load_password_by_id, load_passwords, load_saved_account, password_sort_order, + reorder_passwords, resolve_account_username, save_passwords, }; #[allow(unused_imports)] pub use proxy::{ diff --git a/src-tauri/crates/nyaterm-core/src/config/password.rs b/src-tauri/crates/nyaterm-core/src/config/password.rs index 81c4501a..e070e6ed 100644 --- a/src-tauri/crates/nyaterm-core/src/config/password.rs +++ b/src-tauri/crates/nyaterm-core/src/config/password.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; pub struct SavedPassword { #[serde(default = "uuid_v4")] pub id: String, + #[serde(default)] + pub sort_order: i32, pub name: String, #[serde(default)] pub username: String, @@ -76,6 +78,7 @@ pub fn load_passwords(app: &impl Sized) -> AppResult { let mut config = PasswordsConfig { passwords: storage::list_passwords()?, }; + sort_passwords(&mut config.passwords); for p in &mut config.passwords { p.has_password = p.password.is_some(); } @@ -100,6 +103,41 @@ pub fn load_password_by_id(app: &impl Sized, id: &str) -> AppResult i32 { + config + .passwords + .iter() + .find(|entry| entry.id == id) + .map(|entry| entry.sort_order) + .unwrap_or_else(|| { + config + .passwords + .iter() + .map(|entry| entry.sort_order) + .max() + .unwrap_or(-1) + .saturating_add(1) + }) +} + +pub fn reorder_passwords(config: &mut PasswordsConfig, updates: &[(String, i32)]) { + for (id, sort_order) in updates { + if let Some(entry) = config.passwords.iter_mut().find(|entry| entry.id == *id) { + entry.sort_order = *sort_order; + } + } + sort_passwords(&mut config.passwords); +} + #[cfg(test)] mod tests { use super::*; @@ -143,4 +181,46 @@ mod tests { entry.username = "admin".to_string(); assert_eq!(resolve_account_username(Some(&entry), "root"), "admin"); } + #[test] + fn password_sorting_defaults_preserves_secrets_and_appends() { + let legacy: SavedPassword = serde_json::from_value(serde_json::json!({"id":"b","name":"B","password":"cipher-password","key":"cipher-key","cert":"cipher-cert","passphrase":"cipher-passphrase"})).unwrap(); + assert_eq!(legacy.sort_order, 0); + let mut config: PasswordsConfig = serde_json::from_value(serde_json::json!({"passwords":[ + {"id":"b","name":"B","password":"cipher-password","key":"cipher-key","cert":"cipher-cert","passphrase":"cipher-passphrase"}, + {"id":"a","name":"A"}, + {"id":"c","name":"C","sort_order":4} + ]})).unwrap(); + sort_passwords(&mut config.passwords); + assert_eq!( + config + .passwords + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["a", "b", "c"] + ); + assert_eq!(password_sort_order(&config, "new"), 5); + assert_eq!(password_sort_order(&config, "b"), 0); + let before = serde_json::to_value(&config.passwords[1]).unwrap(); + reorder_passwords( + &mut config, + &[ + ("b".into(), 0), + ("c".into(), 1), + ("a".into(), 2), + ("missing".into(), 3), + ], + ); + assert_eq!( + config + .passwords + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["b", "c", "a"] + ); + assert_eq!(serde_json::to_value(&config.passwords[0]).unwrap(), before); + assert_eq!(password_sort_order(&config, "a"), 2); + assert_eq!(password_sort_order(&config, "new"), 3); + } } diff --git a/src-tauri/crates/nyaterm-core/src/core/importer/merge.rs b/src-tauri/crates/nyaterm-core/src/core/importer/merge.rs index 8c2281ad..4942660f 100644 --- a/src-tauri/crates/nyaterm-core/src/core/importer/merge.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/merge.rs @@ -182,10 +182,16 @@ fn import_prepared_nyaterm_json( } let mut passwords = config::load_passwords(app)?; - passwords.passwords.extend(prepared.passwords); + for mut entry in prepared.passwords { + entry.sort_order = config::password_sort_order(&passwords, &entry.id); + passwords.passwords.push(entry); + } let mut keys = config::load_keys(app)?; - keys.keys.extend(prepared.ssh_keys); + for mut entry in prepared.ssh_keys { + entry.sort_order = config::key_sort_order(&keys, &entry.id); + keys.keys.push(entry); + } crate::storage::backup::import_connections( &config::SessionsConfig { connections: cfg.connections, diff --git a/src-tauri/crates/nyaterm-core/src/core/importer/nyaterm_json.rs b/src-tauri/crates/nyaterm-core/src/core/importer/nyaterm_json.rs index 5e7a3ff9..2ed3ea27 100644 --- a/src-tauri/crates/nyaterm-core/src/core/importer/nyaterm_json.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/nyaterm_json.rs @@ -30,6 +30,7 @@ fn prepare_nyaterm_json_import(file: NyatermJsonImportFile) -> AppResult AppResult AppResult json!(config::load_passwords(&())?.passwords.into_iter().map(|entry| json!({"id":entry.id,"name":entry.name,"username":entry.username,"has_password":entry.password.is_some()})).collect::>()), + "get_saved_passwords" => json!(config::load_passwords(&())?.passwords.into_iter().map(|entry| json!({"id":entry.id,"sort_order":entry.sort_order,"name":entry.name,"username":entry.username,"has_password":entry.password.is_some()})).collect::>()), "save_password" => { let _guard=state.mutation.lock().await; let mut entry:config::SavedPassword=argument(&args,"entry")?;if entry.id.is_empty(){entry.id=uuid::Uuid::new_v4().to_string();} let id=entry.id.clone();let mut config=config::load_passwords(&())?; + entry.sort_order=config::password_sort_order(&config,&id); entry.password=match entry.password.as_deref(){Some("")=>None,Some(secret)=>Some(crypto::encrypt(secret)?),None=>config.passwords.iter().find(|p|p.id==id).and_then(|p|p.password.clone())}; config.passwords.retain(|p|p.id!=id);config.passwords.push(entry);config::save_passwords(&(),&config)?;json!(id) }, "delete_password" => { let _guard=state.mutation.lock().await;let mut config=config::load_passwords(&())?;config.passwords.retain(|p|Some(p.id.as_str())!=args["id"].as_str());config::save_passwords(&(),&config)?;Value::Null }, - "get_ssh_keys" => json!(config::load_keys(&())?.keys.into_iter().map(|entry| json!({"id":entry.id,"name":entry.name,"has_key_data":entry.key.is_some(),"has_cert_data":entry.cert.is_some()})).collect::>()), + "get_ssh_keys" => json!(config::load_keys(&())?.keys.into_iter().map(|entry| json!({"id":entry.id,"sort_order":entry.sort_order,"name":entry.name,"has_key_data":entry.key.is_some(),"has_cert_data":entry.cert.is_some()})).collect::>()), "save_ssh_key" => { let _guard=state.mutation.lock().await; let mut entry:config::SshKey=argument(&args,"key")?; @@ -219,6 +220,7 @@ pub async fn route( let mut config=config::load_keys(&())?;let existing=config.keys.iter().find(|p|p.id==id); // The client can supply plaintext only via transient key_data. It may // never inject an encrypted on-disk token or a server filesystem path. + entry.sort_order=config::key_sort_order(&config,&id); entry.key=match entry.key_data.take(){Some(data)=>{services::validate_private_key_content(&data,entry.passphrase.as_deref())?;Some(crypto::encrypt(&data)?)},None=>existing.and_then(|k|k.key.clone())}; entry.cert=match entry.cert_data.take(){Some(data)=>{services::validate_certificate_content(&data)?;Some(crypto::encrypt(&data)?)},None=>existing.and_then(|k|k.cert.clone())}; if entry.key.is_none() { return Err(WebError::bad("Private key required")); } @@ -226,6 +228,8 @@ pub async fn route( config.keys.retain(|p|p.id!=id);config.keys.push(entry);config::save_keys(&(),&config)?;json!(id) }, "delete_ssh_key" => { let _guard=state.mutation.lock().await;let mut config=config::load_keys(&())?;config.keys.retain(|p|Some(p.id.as_str())!=args["id"].as_str());config::save_keys(&(),&config)?;Value::Null }, + "reorder_passwords" => { let _guard=state.mutation.lock().await;let updates:Vec=argument(&args,"updates")?;let mut cfg=config::load_passwords(&())?;config::reorder_passwords(&mut cfg,&updates.into_iter().map(|u|(u.id,u.sort_order)).collect::>());config::save_passwords(&(),&cfg)?;Value::Null }, + "reorder_ssh_keys" => { let _guard=state.mutation.lock().await;let updates:Vec=argument(&args,"updates")?;let mut cfg=config::load_keys(&())?;config::reorder_ssh_keys(&mut cfg,&updates.into_iter().map(|u|(u.id,u.sort_order)).collect::>());config::save_keys(&(),&cfg)?;Value::Null }, "get_saved_credentials" => { let entries=config::load_credentials(&())?.credentials;json!(entries.into_iter().map(|mut e| { let exists=e.password.is_some();e.password=None;let mut value=json!(e);value["has_password"]=json!(exists);value }).collect::>()) }, "save_credential" => { let _guard=state.mutation.lock().await;let entry:config::SavedCredential=argument(&args,"entry")?;{ let mut config=config::load_credentials(&())?;let id=config::upsert_credential(&mut config,entry)?;config::save_credentials(&(),&config)?;state.broadcast("credentials-changed",Value::Null).await;json!(id) } }, "get_connection_password_value" => {let conn=config::load_connection_by_id(&(),text(&args,"id")?)?;json!(conn.auth.as_ref().map(|a|crypto::decrypt_optional(&a.password)).transpose()?.flatten())}, diff --git a/src-tauri/crates/nyaterm-web/tests/commands.rs b/src-tauri/crates/nyaterm-web/tests/commands.rs index 6c92e902..b4a25cbd 100644 --- a/src-tauri/crates/nyaterm-web/tests/commands.rs +++ b/src-tauri/crates/nyaterm-web/tests/commands.rs @@ -207,6 +207,99 @@ async fn persistent_web_commands_preserve_contracts_and_verify_real_passwords() "b" ); + // Legacy rows have no explicit sort order. Reorder persists metadata only. + let password_ciphertext = crypto::encrypt("saved-account-secret").unwrap(); + let key_ciphertext = crypto::encrypt("saved-private-key").unwrap(); + let cert_ciphertext = crypto::encrypt("saved-certificate").unwrap(); + let passphrase_ciphertext = crypto::encrypt("saved-passphrase").unwrap(); + config::save_passwords( + &(), + &serde_json::from_value(json!({"passwords":[ + {"id":"z","name":"Z","username":"root","password":password_ciphertext}, + {"id":"a","name":"A","username":"admin"} + ]})) + .unwrap(), + ) + .unwrap(); + config::save_keys(&(), &serde_json::from_value(json!({"keys":[ + {"id":"z","name":"Z","key":key_ciphertext,"cert":cert_ciphertext,"passphrase":passphrase_ciphertext}, + {"id":"a","name":"A","key":key_ciphertext} + ]})).unwrap()).unwrap(); + for (list, reorder) in [ + ("get_saved_passwords", "reorder_passwords"), + ("get_ssh_keys", "reorder_ssh_keys"), + ] { + let before = ok(&app, list, json!({})).await; + assert_eq!(before[0]["id"], "a"); + assert_eq!(before[0]["sort_order"], 0); + ok( + &app, + reorder, + json!({"updates":[{"id":"z","sort_order":0},{"id":"a","sort_order":1}]}), + ) + .await; + let after = ok(&app, list, json!({})).await; + assert_eq!(after[0]["id"], "z"); + assert_eq!(after[1]["sort_order"], 1); + assert!(after[0].get("password").is_none()); + assert!(after[0].get("key").is_none()); + assert!(after[0].get("passphrase").is_none()); + } + let passwords = config::load_passwords(&()).unwrap(); + assert_eq!( + passwords.passwords[0].password.as_ref(), + Some(&password_ciphertext) + ); + let keys = config::load_keys(&()).unwrap(); + assert_eq!(keys.keys[0].key.as_ref(), Some(&key_ciphertext)); + assert_eq!(keys.keys[0].cert.as_ref(), Some(&cert_ciphertext)); + assert_eq!( + keys.keys[0].passphrase.as_ref(), + Some(&passphrase_ciphertext) + ); + // Even clients omitting sort_order preserve position on edits. + ok( + &app, + "save_password", + json!({"entry":{"id":"a","name":"Edited account","username":"admin"}}), + ) + .await; + ok( + &app, + "save_ssh_key", + json!({"key":{"id":"a","name":"Edited key"}}), + ) + .await; + assert_eq!( + config::load_passwords(&()).unwrap().passwords[1].sort_order, + 1 + ); + assert_eq!(config::load_keys(&()).unwrap().keys[1].sort_order, 1); + let new_id = ok( + &app, + "save_password", + json!({"entry":{"id":"","name":"Appended account","username":"new"}}), + ) + .await; + let passwords = config::load_passwords(&()).unwrap(); + assert_eq!(passwords.passwords[2].id, new_id.as_str().unwrap()); + assert_eq!(passwords.passwords[2].sort_order, 2); + let generated = + russh::keys::PrivateKey::random(&mut rand_new::rng(), russh::keys::Algorithm::Ed25519) + .unwrap(); + let pem = generated + .to_openssh(russh::keys::ssh_key::LineEnding::LF) + .unwrap(); + let new_id = ok( + &app, + "save_ssh_key", + json!({"key":{"id":"","name":"Appended key","key_data":pem.as_str()}}), + ) + .await; + let keys = config::load_keys(&()).unwrap(); + assert_eq!(keys.keys[2].id, new_id.as_str().unwrap()); + assert_eq!(keys.keys[2].sort_order, 2); + let entry = json!({"id":"otp","otp_type":"hotp","issuer":"RFC4226","username":"test","secret":"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ","algorithm":"SHA1","digits":6,"period":30,"counter":0}); ok(&app, "save_otp_entry", json!({"entry":entry})).await; let otp = ok(&app, "get_otp_entries", json!({})).await; diff --git a/src-tauri/src/cmd/connection.rs b/src-tauri/src/cmd/connection.rs index be3788d1..e78c1380 100644 --- a/src-tauri/src/cmd/connection.rs +++ b/src-tauri/src/cmd/connection.rs @@ -531,6 +531,7 @@ e+JpiSq66Z6GIt0801skPh20jxOO3F52SoX1IeO5D5PXfZrfSZlw6S8c7bwyp2FHxDewRx #[test] fn saved_account_list_redacts_password_but_keeps_metadata() { let summary = SavedAccountSummary::from(SavedPassword { + sort_order: 0, id: "account-1".to_string(), name: "Production".to_string(), username: "admin".to_string(), @@ -830,6 +831,7 @@ e+JpiSq66Z6GIt0801skPh20jxOO3F52SoX1IeO5D5PXfZrfSZlw6S8c7bwyp2FHxDewRx key_file_path: Option<&str>, ) -> SshKey { SshKey { + sort_order: 0, id: id.to_string(), name: format!("Key {id}"), key: None, @@ -1535,6 +1537,7 @@ pub fn save_ssh_key(app: tauri::AppHandle, mut key: SshKey) -> AppResult let target_id = key.id.clone(); let existing = cfg.keys.iter().find(|k| k.id == target_id); + key.sort_order = config::key_sort_order(&cfg, &target_id); key.key = resolve_private_key_for_save(&key, existing)?; if key.key.is_none() { return Err(AppError::Config("SSH private key is required".to_string())); @@ -1796,6 +1799,7 @@ pub fn import_quick_commands( #[derive(Debug, serde::Serialize)] pub struct SavedAccountSummary { id: String, + sort_order: i32, name: String, username: String, has_password: bool, @@ -1805,6 +1809,7 @@ impl From for SavedAccountSummary { fn from(entry: SavedPassword) -> Self { Self { id: entry.id, + sort_order: entry.sort_order, name: entry.name, username: entry.username, has_password: entry.has_password, @@ -1848,6 +1853,7 @@ pub fn save_password(app: tauri::AppHandle, mut entry: SavedPassword) -> AppResu let target_id = entry.id.clone(); let existing = cfg.passwords.iter().find(|p| p.id == target_id); + entry.sort_order = config::password_sort_order(&cfg, &target_id); entry.password = resolve_account_password_update( entry.password.as_deref(), existing.and_then(|entry| entry.password.as_deref()), @@ -1871,3 +1877,45 @@ pub fn delete_password(app: tauri::AppHandle, id: String) -> AppResult<()> { schedule_cloud_sync_notify(app.clone()); Ok(()) } + +#[derive(serde::Deserialize)] +pub struct SecretSortOrderUpdate { + pub id: String, + pub sort_order: i32, +} + +#[tauri::command] +pub fn reorder_passwords( + app: tauri::AppHandle, + updates: Vec, +) -> AppResult<()> { + let mut cfg = config::load_passwords(&app)?; + config::reorder_passwords( + &mut cfg, + &updates + .into_iter() + .map(|update| (update.id, update.sort_order)) + .collect::>(), + ); + config::save_passwords(&app, &cfg)?; + schedule_cloud_sync_notify(app); + Ok(()) +} + +#[tauri::command] +pub fn reorder_ssh_keys( + app: tauri::AppHandle, + updates: Vec, +) -> AppResult<()> { + let mut cfg = config::load_keys(&app)?; + config::reorder_ssh_keys( + &mut cfg, + &updates + .into_iter() + .map(|update| (update.id, update.sort_order)) + .collect::>(), + ); + config::save_keys(&app, &cfg)?; + schedule_cloud_sync_notify(app); + Ok(()) +} diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index 7c4c1d8d..ea485025 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -858,6 +858,7 @@ mod tests { ..ConnectionAuth::default() }; let account = config::SavedPassword { + sort_order: 0, id: "saved-1".to_string(), name: "Saved".to_string(), username: String::new(), @@ -880,6 +881,7 @@ mod tests { ..ConnectionAuth::default() }; let account = config::SavedPassword { + sort_order: 0, id: "account-1".to_string(), name: "Account".to_string(), username: "admin".to_string(), @@ -903,6 +905,7 @@ mod tests { ..ConnectionAuth::default() }; let account = config::SavedPassword { + sort_order: 0, id: "account-1".to_string(), name: "Account".to_string(), username: "admin".to_string(), diff --git a/src-tauri/src/core/ssh/auth.rs b/src-tauri/src/core/ssh/auth.rs index 413f86c8..2f33ff57 100644 --- a/src-tauri/src/core/ssh/auth.rs +++ b/src-tauri/src/core/ssh/auth.rs @@ -2168,6 +2168,7 @@ fn upsert_runtime_saved_password( .map(|password| password.username.clone()) .unwrap_or_else(|| username.to_string()); let entry = crate::config::SavedPassword { + sort_order: crate::config::password_sort_order(&cfg, &target_id), id: target_id.clone(), name: entry_name, username: entry_username, @@ -3162,6 +3163,7 @@ mod tests { fn test_account(username: &str, password: Option<&str>) -> SavedPassword { SavedPassword { + sort_order: 0, id: "account-1".to_string(), name: "Account".to_string(), username: username.to_string(), diff --git a/src-tauri/src/core/ssh_config.rs b/src-tauri/src/core/ssh_config.rs index 6e79b6ca..89ac8398 100644 --- a/src-tauri/src/core/ssh_config.rs +++ b/src-tauri/src/core/ssh_config.rs @@ -734,6 +734,12 @@ where ); keys.push(SshKey { + sort_order: keys + .iter() + .map(|key| key.sort_order) + .max() + .unwrap_or(-1) + .saturating_add(1), id: key_id.clone(), name, key: Some(encrypt(&content)?), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ea4511e5..ca1b7802 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -403,6 +403,8 @@ pub fn run() { cmd::credential::save_credential, cmd::credential::delete_credential, cmd::credential::reorder_credentials, + cmd::connection::reorder_passwords, + cmd::connection::reorder_ssh_keys, cmd::settings::get_app_settings, cmd::settings::save_app_settings, cmd::settings::save_app_language, diff --git a/src/components/panel/security-auth/KeyManagementTab.test.tsx b/src/components/panel/security-auth/KeyManagementTab.test.tsx index 65118126..e1b8c5ee 100644 --- a/src/components/panel/security-auth/KeyManagementTab.test.tsx +++ b/src/components/panel/security-auth/KeyManagementTab.test.tsx @@ -25,6 +25,7 @@ vi.mock("@/components/dialog/security-auth/PrivateKeyViewDialog", () => ({ })); const key: SshKey = { + sort_order: 0, id: "key-1", name: "Production key", has_key_data: true, diff --git a/src/components/panel/security-auth/KeyManagementTab.tsx b/src/components/panel/security-auth/KeyManagementTab.tsx index 32ee9a86..5acd4d3f 100644 --- a/src/components/panel/security-auth/KeyManagementTab.tsx +++ b/src/components/panel/security-auth/KeyManagementTab.tsx @@ -1,4 +1,4 @@ -import { Copy, KeyRound } from "lucide-react"; +import { Copy, GripVertical, KeyRound } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdAdd, MdDelete, MdEdit } from "react-icons/md"; @@ -20,6 +20,7 @@ import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import type { SshKey } from "@/types/global"; import { SecretUnlockFooter } from "./SecretUnlockFooter"; +import { useSecretListSorting } from "./useSecretListSorting"; interface KeyManagementTabProps { onCountChange?: (count: number) => void; @@ -254,7 +255,9 @@ export function KeyManagementTab({ setPrivateKeyError(false); setPrivateKeyLoading(true); try { - const value = await invoke("get_ssh_key_private_key", { id: key.id }); + const value = await invoke("get_ssh_key_private_key", { + id: key.id, + }); setPrivateKeyValue(value ?? ""); } catch { setPrivateKeyError(true); @@ -267,7 +270,9 @@ export function KeyManagementTab({ async (key: SshKey) => { setPublicKeyLoadingId(key.id); try { - const value = await invoke("get_ssh_key_public_key", { id: key.id }); + const value = await invoke("get_ssh_key_public_key", { + id: key.id, + }); await writeClipboardText(value); toast.success(t("settings.copyPublicKeySuccess")); } catch (error) { @@ -383,6 +388,15 @@ export function KeyManagementTab({ } }; + const { actionsDisabled, reordering, handleProps, rowProps } = useSecretListSorting( + keys, + setKeys, + loadKeys, + editingId !== null, + "reorder_ssh_keys", + "settings.keyReorderFailed", + ); + const lockedHint = !secretsUnlocked ? t("secretUnlock.lockedActionHint") : undefined; const hasResolvedKeySource = editKeyData.trim().length > 0 || @@ -400,7 +414,7 @@ export function KeyManagementTab({ size="sm" className="h-7 shrink-0 px-2 text-xs text-primary" onClick={handleAdd} - disabled={editingId !== null} + disabled={actionsDisabled} > {t("settings.addKey")} @@ -410,8 +424,27 @@ export function KeyManagementTab({ {keys.map((key) => (
+ + + + + + + + {t(reordering ? "settings.keyReordering" : "settings.keyDragToSort")} + + {key.name}
@@ -423,7 +456,7 @@ export function KeyManagementTab({ onClick={() => { void handleCopyPublicKey(key); }} - disabled={editingId !== null || publicKeyLoadingId === key.id} + disabled={actionsDisabled || publicKeyLoadingId === key.id} aria-label={t("settings.copyPublicKey")} > @@ -441,7 +474,7 @@ export function KeyManagementTab({ onClick={() => { runUnlockedAction(() => handleViewPrivateKey(key)); }} - disabled={editingId !== null || privateKeyLoading} + disabled={actionsDisabled || privateKeyLoading} aria-label={t("settings.viewPrivateKey")} > @@ -458,7 +491,8 @@ export function KeyManagementTab({ onClick={() => { void handleEdit(key); }} - disabled={editingId !== null} + disabled={actionsDisabled} + aria-label={t("common.edit")} > @@ -467,7 +501,8 @@ export function KeyManagementTab({ size="icon-sm" className="text-destructive hover:bg-destructive/10" onClick={() => setDeletingKey(key)} - disabled={editingId !== null} + disabled={actionsDisabled} + aria-label={t("common.delete")} > diff --git a/src/components/panel/security-auth/PasswordManagementTab.test.tsx b/src/components/panel/security-auth/PasswordManagementTab.test.tsx index b97a74f1..0836e96b 100644 --- a/src/components/panel/security-auth/PasswordManagementTab.test.tsx +++ b/src/components/panel/security-auth/PasswordManagementTab.test.tsx @@ -13,6 +13,7 @@ vi.mock("./SecretUnlockFooter", () => ({ SecretUnlockFooter: () => null })); vi.mock("./CopyButton", () => ({ CopyButton: () => null })); const account: SavedAccount = { + sort_order: 0, id: "account-1", name: "Production", username: "root", diff --git a/src/components/panel/security-auth/PasswordManagementTab.tsx b/src/components/panel/security-auth/PasswordManagementTab.tsx index 49d2419f..43db0d92 100644 --- a/src/components/panel/security-auth/PasswordManagementTab.tsx +++ b/src/components/panel/security-auth/PasswordManagementTab.tsx @@ -1,4 +1,4 @@ -import { Eye, EyeOff } from "lucide-react"; +import { Eye, EyeOff, GripVertical } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdAdd, MdDelete, MdEdit } from "react-icons/md"; @@ -18,6 +18,7 @@ import { invoke } from "@/lib/invoke"; import type { SavedAccount, SavedConnection } from "@/types/global"; import { CopyButton } from "./CopyButton"; import { SecretUnlockFooter } from "./SecretUnlockFooter"; +import { useSecretListSorting } from "./useSecretListSorting"; interface PasswordManagementTabProps { onCountChange?: (count: number) => void; @@ -213,7 +214,9 @@ export function PasswordManagementTab({ setRevealLoadingIds((prev) => new Set(prev).add(id)); try { - const value = await invoke("get_saved_password_value", { id }); + const value = await invoke("get_saved_password_value", { + id, + }); setPasswordCache((prev) => ({ ...prev, [id]: value ?? "" })); setRevealedIds((prev) => new Set(prev).add(id)); } catch { @@ -340,6 +343,15 @@ export function PasswordManagementTab({ } }, [onUnlockSecrets]); + const { actionsDisabled, reordering, handleProps, rowProps } = useSecretListSorting( + passwords, + setPasswords, + loadPasswords, + editingId !== null, + "reorder_passwords", + "passwordManager.reorderFailed", + ); + const lockedHint = !secretsUnlocked ? t(showSecretUnlockFooter ? "secretUnlock.lockedActionHint" : "secretUnlock.unlockTitle") : undefined; @@ -360,7 +372,7 @@ export function PasswordManagementTab({ size="sm" className="h-7 shrink-0 px-2 text-xs text-primary" onClick={handleAdd} - disabled={editingId !== null} + disabled={actionsDisabled} > {t("passwordManager.add")} @@ -370,8 +382,27 @@ export function PasswordManagementTab({ {passwords.map((entry) => (
+ + + + + + + + {t(reordering ? "passwordManager.reordering" : "passwordManager.dragToSort")} + +
{entry.name}
@@ -409,9 +440,7 @@ export function PasswordManagementTab({ } }} disabled={ - editingId !== null || - revealLoadingIds.has(entry.id) || - !entry.has_password + actionsDisabled || revealLoadingIds.has(entry.id) || !entry.has_password } aria-label={ revealedIds.has(entry.id) @@ -443,7 +472,7 @@ export function PasswordManagementTab({ onClick={() => { runUnlockedAction(() => handleEdit(entry)); }} - disabled={editingId !== null} + disabled={actionsDisabled} aria-label={t("common.edit")} > @@ -462,7 +491,7 @@ export function PasswordManagementTab({ onClick={() => { runUnlockedAction(() => setDeletingEntry(entry)); }} - disabled={editingId !== null} + disabled={actionsDisabled} aria-label={t("common.delete")} > @@ -537,7 +566,9 @@ export function PasswordManagementTab({ {t("passwordManager.deleteTitle")} - {t("passwordManager.deleteConfirm", { name: deletingEntry?.name })} + {t("passwordManager.deleteConfirm", { + name: deletingEntry?.name, + })} {deletingReferences.length > 0 ? (
diff --git a/src/components/panel/security-auth/SecretListSorting.test.tsx b/src/components/panel/security-auth/SecretListSorting.test.tsx new file mode 100644 index 00000000..f9b599d1 --- /dev/null +++ b/src/components/panel/security-auth/SecretListSorting.test.tsx @@ -0,0 +1,186 @@ +import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { KeyManagementTab } from "./KeyManagementTab"; +import { PasswordManagementTab } from "./PasswordManagementTab"; + +const { invokeMock, errorMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + errorMock: vi.fn(), +})); +vi.mock("@/lib/invoke", () => ({ invoke: invokeMock })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("sonner", () => ({ toast: { error: errorMock } })); +vi.mock("./SecretUnlockFooter", () => ({ SecretUnlockFooter: () => null })); +vi.mock("./CopyButton", () => ({ CopyButton: () => null })); +vi.mock("@/components/dialog/security-auth/KeyEditorDialog", () => ({ + KeyEditorDialog: () => null, +})); +vi.mock("@/components/dialog/security-auth/KeyDeleteDialog", () => ({ + KeyDeleteDialog: () => null, +})); +vi.mock("@/components/dialog/security-auth/PrivateKeyViewDialog", () => ({ + PrivateKeyViewDialog: () => null, +})); + +const entries = ["Alpha", "Beta", "Gamma"].map((name, index) => ({ + id: name.toLowerCase(), + name, + username: "root", + sort_order: index, + has_password: true, + has_key_data: true, +})); + +function row(name: string) { + const element = screen.getByText(name).closest(".security-auth-action-row"); + if (!element) throw new Error(`Missing row ${name}`); + return element; +} + +function drag(source: string, target: string, position: "before" | "after") { + const handle = row(source).querySelector("button"); + if (!handle) throw new Error("Missing drag handle"); + const dataTransfer = { setData: vi.fn(), effectAllowed: "", dropEffect: "" }; + fireEvent.dragStart(handle, { dataTransfer }); + for (const type of ["dragOver", "drop"] as const) { + const event = createEvent[type](row(target), { dataTransfer }); + Object.defineProperty(event, "clientY", { + value: position === "before" ? -1 : 1, + }); + fireEvent(row(target), event); + } +} + +function names() { + return Array.from(document.querySelectorAll(".security-auth-action-row")).map( + (element) => entries.find((entry) => element.textContent?.includes(entry.name))?.name, + ); +} + +describe.each([ + { + Panel: PasswordManagementTab, + list: "get_saved_passwords", + command: "reorder_passwords", + label: "passwordManager.dragToSort", + failure: "passwordManager.reorderFailed", + }, + { + Panel: KeyManagementTab, + list: "get_ssh_keys", + command: "reorder_ssh_keys", + label: "settings.keyDragToSort", + failure: "settings.keyReorderFailed", + }, +])("$command", ({ Panel, list, command, label, failure }) => { + beforeEach(() => { + invokeMock.mockReset(); + errorMock.mockReset(); + invokeMock.mockImplementation((name: string) => { + if (name === list) return Promise.resolve(entries); + if (name === "get_saved_connections") return Promise.resolve([]); + if (name === command) return Promise.resolve(); + return Promise.reject(new Error(`Unexpected command ${name}`)); + }); + }); + + it.each([ + ["Gamma", "Alpha", "before", ["Gamma", "Alpha", "Beta"]], + ["Alpha", "Gamma", "after", ["Beta", "Gamma", "Alpha"]], + ] as const)("moves %s relative to %s while locked", async (source, target, position, expected) => { + render(); + await screen.findByText("Alpha"); + drag(source, target, position); + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith(command, { + updates: expected.map((name, sort_order) => ({ + id: name.toLowerCase(), + sort_order, + })), + }), + ); + expect(names()).toEqual(expected); + }); + + it("skips self drops and unchanged order", async () => { + render(); + await screen.findByText("Alpha"); + drag("Alpha", "Alpha", "before"); + drag("Alpha", "Beta", "before"); + expect(invokeMock).not.toHaveBeenCalledWith(command, expect.anything()); + expect(names()).toEqual(["Alpha", "Beta", "Gamma"]); + }); + + it("clears drag state on cancellation", async () => { + render(); + await screen.findByText("Alpha"); + const handle = screen.getAllByRole("button", { name: label })[0]; + fireEvent.dragStart(handle, { dataTransfer: { setData: vi.fn() } }); + expect(row("Alpha").style.opacity).toBe("0.5"); + fireEvent.dragEnd(handle); + expect(row("Alpha").style.opacity).toBe(""); + expect(invokeMock).not.toHaveBeenCalledWith(command, expect.anything()); + }); + + it("disables actions while saving and prevents a second reorder", async () => { + let resolveSave = () => {}; + invokeMock.mockImplementation((name: string) => { + if (name === command) + return new Promise((resolve) => { + resolveSave = resolve; + }); + return Promise.resolve(name === list ? entries : []); + }); + render(); + await screen.findByText("Alpha"); + drag("Gamma", "Alpha", "before"); + expect( + screen + .getAllByRole("button", { name: label }) + .every((button) => button.hasAttribute("disabled")), + ).toBe(true); + expect(row("Alpha").querySelectorAll("button:enabled")).toHaveLength(0); + drag("Alpha", "Beta", "after"); + expect(invokeMock.mock.calls.filter(([name]) => name === command)).toHaveLength(1); + resolveSave(); + await waitFor(() => + expect(screen.getAllByRole("button", { name: label })[0].hasAttribute("disabled")).toBe( + false, + ), + ); + }); + + it("restores the previous order even when the failure reload also fails", async () => { + let loads = 0; + invokeMock.mockImplementation((name: string) => { + if (name === list) + return ++loads === 1 ? Promise.resolve(entries) : Promise.reject(new Error("offline")); + if (name === command) return Promise.reject(new Error("save failed")); + return Promise.resolve([]); + }); + render(); + await screen.findByText("Alpha"); + drag("Gamma", "Alpha", "before"); + await waitFor(() => expect(errorMock).toHaveBeenCalledWith(failure)); + expect(names()).toEqual(["Alpha", "Beta", "Gamma"]); + await waitFor(() => expect(loads).toBe(2)); + }); + + it("disables dragging while editing", async () => { + render(); + await screen.findByText("Alpha"); + const handle = screen.getAllByRole("button", { name: label })[0]; + fireEvent.click(screen.getAllByRole("button", { name: "common.edit" })[0]); + await waitFor(() => expect(handle.hasAttribute("disabled")).toBe(true)); + }); + + it("disables the handle for a single entry", async () => { + invokeMock.mockResolvedValue([entries[0]]); + render(); + await screen.findByText("Alpha"); + expect(screen.getByRole("button", { name: label }).hasAttribute("disabled")).toBe(true); + expect(screen.getByRole("button", { name: label }).getAttribute("draggable")).toBe("false"); + }); +}); diff --git a/src/components/panel/security-auth/useSecretListSorting.ts b/src/components/panel/security-auth/useSecretListSorting.ts new file mode 100644 index 00000000..4ff7a958 --- /dev/null +++ b/src/components/panel/security-auth/useSecretListSorting.ts @@ -0,0 +1,108 @@ +import { type Dispatch, type DragEvent, type SetStateAction, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { invoke } from "@/lib/invoke"; + +type SortableEntry = { id: string; sort_order: number }; +type DropTarget = { id: string; position: "before" | "after" }; + +function dropPosition(event: DragEvent): DropTarget["position"] { + const rect = event.currentTarget.getBoundingClientRect(); + return event.clientY < rect.top + rect.height / 2 ? "before" : "after"; +} + +export function useSecretListSorting( + entries: T[], + setEntries: Dispatch>, + reload: () => Promise, + editing: boolean, + command: "reorder_passwords" | "reorder_ssh_keys", + failureMessage: string, +) { + const { t } = useTranslation(); + const sourceRef = useRef(null); + const savingRef = useRef(false); + const [draggingId, setDraggingId] = useState(null); + const [target, setTarget] = useState(null); + const [reordering, setReordering] = useState(false); + const actionsDisabled = editing || reordering; + + const resetDrag = () => { + sourceRef.current = null; + setDraggingId(null); + setTarget(null); + }; + + const onDrop = async (event: DragEvent, targetId: string) => { + event.preventDefault(); + const sourceId = sourceRef.current; + const position = dropPosition(event); + resetDrag(); + if (!sourceId || sourceId === targetId || editing || savingRef.current) return; + + const source = entries.find((entry) => entry.id === sourceId); + const next = entries.filter((entry) => entry.id !== sourceId); + const targetIndex = next.findIndex((entry) => entry.id === targetId); + if (!source || targetIndex < 0) return; + next.splice(targetIndex + (position === "after" ? 1 : 0), 0, source); + if (next.every((entry, index) => entry.id === entries[index].id)) return; + + const reordered = next.map((entry, index) => ({ + ...entry, + sort_order: index, + })); + savingRef.current = true; + setReordering(true); + setEntries(reordered); + try { + await invoke(command, { + updates: reordered.map(({ id, sort_order }) => ({ id, sort_order })), + }); + } catch { + setEntries(entries); + toast.error(t(failureMessage)); + await reload(); + } finally { + savingRef.current = false; + setReordering(false); + } + }; + + return { + actionsDisabled, + reordering, + handleProps: (id: string) => ({ + draggable: !actionsDisabled && entries.length > 1, + disabled: actionsDisabled || entries.length < 2, + onDragStart: (event: DragEvent) => { + if (editing || savingRef.current || entries.length < 2) { + event.preventDefault(); + return; + } + sourceRef.current = id; + setDraggingId(id); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", id); + }, + onDragEnd: resetDrag, + }), + rowProps: (id: string) => ({ + style: { + opacity: draggingId === id ? 0.5 : undefined, + boxShadow: + target?.id === id + ? `inset 0 ${target.position === "before" ? "2px" : "-2px"} 0 var(--df-primary)` + : undefined, + }, + onDragOver: (event: DragEvent) => { + if (!sourceRef.current || sourceRef.current === id || editing || savingRef.current) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setTarget({ id, position: dropPosition(event) }); + }, + onDrop: (event: DragEvent) => { + void onDrop(event, id); + }, + }), + }; +} diff --git a/src/pages/NewSessionPage.test.tsx b/src/pages/NewSessionPage.test.tsx index f944d9f9..75b827f9 100644 --- a/src/pages/NewSessionPage.test.tsx +++ b/src/pages/NewSessionPage.test.tsx @@ -176,6 +176,7 @@ const jumpHost: SavedConnection = { }; const account: SavedAccount = { + sort_order: 0, id: "account-1", name: "Production", username: "admin", diff --git a/src/types/global.d.ts b/src/types/global.d.ts index b486820c..7f92f087 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -307,6 +307,7 @@ export interface Group { /** Managed SSH private key stored in local app storage. */ export interface SshKey { id: string; + sort_order: number; name: string; /** Transient: plaintext private key content pasted from the UI. */ key_data?: string; @@ -339,6 +340,7 @@ export interface KnownHostEntry { /** Managed account entry stored in local app storage. */ export interface SavedAccount { id: string; + sort_order: number; name: string; username: string; /** True when encrypted password data exists in local storage. */