feat(security-auth): implement drag-and-drop sorting for SSH keys and passwords (#738)

Added a new `useSecretListSorting` hook to manage the drag-and-drop functionality for reordering SSH keys and passwords. Updated the `KeyManagementTab` and `PasswordManagementTab` components to utilize this hook, allowing users to reorder entries visually. Introduced sort_order property to both SshKey and SavedAccount types to maintain the order of entries. Added tests to ensure the sorting functionality works as expected.
This commit is contained in:
Kang committed 2026-10-07 13:01:23 +08:00
1 parent f49aa85f7c
commit 073c0d5a20
23 files changed
+757 -25

No files matched your search

@@ -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<KeysConfig> {
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<Option<String>> {
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<_>>(),
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<_>>(),
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);
}
}
@@ -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::{
@@ -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<PasswordsConfig> {
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<SavedPasswor
Ok(entry)
}
pub fn sort_passwords(entries: &mut [SavedPassword]) {
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 password_sort_order(config: &PasswordsConfig, id: &str) -> 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<_>>(),
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<_>>(),
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);
}
}
@@ -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,
@@ -30,6 +30,7 @@ fn prepare_nyaterm_json_import(file: NyatermJsonImportFile) -> AppResult<Prepare
let id = uuid::Uuid::new_v4().to_string();
password_ref_map.insert(ref_name, id.clone());
passwords.push(config::SavedPassword {
sort_order: 0,
id,
name: required_string(entry.name, "password name", "passwords")?,
username: String::new(),
@@ -56,6 +57,7 @@ fn prepare_nyaterm_json_import(file: NyatermJsonImportFile) -> AppResult<Prepare
let id = uuid::Uuid::new_v4().to_string();
key_ref_map.insert(ref_name, id.clone());
ssh_keys.push(config::SshKey {
sort_order: 0,
id,
name: required_string(entry.name, "ssh key name", "ssh_keys")?,
key: Some(encrypt_import_secret(&entry.private_key)?),
@@ -938,6 +938,7 @@ fn prepare_termius_keys(keys: &[TermiusRawSshKey]) -> AppResult<PreparedTermiusK
}
ids.insert(termius_ssh_key_key(key), id.clone());
prepared.push(config::SshKey {
sort_order: 0,
id,
name: normalize_optional_string(key.label.clone())
.unwrap_or_else(|| "Termius SSH Key".to_string()),
@@ -979,6 +980,7 @@ fn prepare_termius_passwords(
ids.insert(format!("host:{alias}"), id.clone());
}
passwords.push(config::SavedPassword {
sort_order: 0,
id,
name: format!(
"{} password",
@@ -1004,6 +1006,7 @@ fn prepare_termius_passwords(
ids.insert(format!("identity:{alias}"), id.clone());
}
passwords.push(config::SavedPassword {
sort_order: 0,
id,
name: format!(
"{} password",
@@ -1120,6 +1123,7 @@ fn align_termius_account_username(
let replacement_id = uuid::Uuid::new_v4().to_string();
prepared_passwords.passwords.push(config::SavedPassword {
sort_order: 0,
id: replacement_id.clone(),
username: username.to_string(),
..account
@@ -446,6 +446,7 @@ fn import_windterm_key(
|file_name| format!("{session_name} ({file_name})"),
);
ssh_keys.push(config::SshKey {
sort_order: 0,
id: id.clone(),
name: display_name,
key: Some(encrypt_import_secret(&key_content)?),
@@ -843,6 +843,7 @@ mod tests {
fn portable_snapshot_zip_roundtrip() {
let mut snapshot = sample_snapshot();
snapshot.passwords.passwords.push(config::SavedPassword {
sort_order: 0,
id: "account-1".to_string(),
name: "Production".to_string(),
username: "admin".to_string(),
@@ -1233,4 +1234,37 @@ mod tests {
"compressed snapshot should be smaller than legacy redb"
);
}
#[test]
fn portable_snapshot_secret_sort_order_roundtrip_and_legacy_defaults() {
let mut snapshot = sample_snapshot();
snapshot.passwords = serde_json::from_value(serde_json::json!({"passwords":[{"id":"account","name":"Account","sort_order":7,"password":"cipher-password"}]})).unwrap();
snapshot.keys = serde_json::from_value(serde_json::json!({"keys":[{"id":"key","name":"Key","sort_order":9,"key":"cipher-key","cert":"cipher-cert","passphrase":"cipher-passphrase"}]})).unwrap();
snapshot.payload_hash = calculate_payload_hash(&snapshot).unwrap();
let encoded = encode_portable_snapshot(&snapshot).unwrap();
let decoded = super::decode_portable_snapshot(&encoded).unwrap();
assert_eq!(decoded.passwords.passwords[0].sort_order, 7);
assert_eq!(decoded.keys.keys[0].sort_order, 9);
assert_eq!(decoded.keys.keys[0].key.as_deref(), Some("cipher-key"));
assert_eq!(decoded.keys.keys[0].cert.as_deref(), Some("cipher-cert"));
assert_eq!(
decoded.keys.keys[0].passphrase.as_deref(),
Some("cipher-passphrase")
);
assert_eq!(
decoded.passwords.passwords[0].password.as_deref(),
Some("cipher-password")
);
let mut entities = snapshot_entities(&snapshot);
for (name, list) in [("keys", "keys"), ("passwords", "passwords")] {
let mut value: serde_json::Value = serde_json::from_str(&entities[name]).unwrap();
value[list][0].as_object_mut().unwrap().remove("sort_order");
entities.insert(name.into(), serde_json::to_string(&value).unwrap());
}
let source_hash = calculate_v3_raw_payload_hash(&entities).unwrap();
let encoded = encode_v3_raw_snapshot_redb_for_test(&snapshot, &entities, source_hash);
let decoded = super::decode_portable_snapshot(&encoded).unwrap();
assert_eq!(decoded.keys.keys[0].sort_order, 0);
assert_eq!(decoded.passwords.passwords[0].sort_order, 0);
}
}
+6 -2
View File
@@ -201,16 +201,17 @@ pub async fn route(
config.groups.retain(|g|g.id!=id);config.groups.push(group);config::save_config(&(),&config)?;
state.broadcast("connections-changed",Value::Null).await;json!(id)
},
"get_saved_passwords" => 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::<Vec<_>>()),
"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::<Vec<_>>()),
"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::<Vec<_>>()),
"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::<Vec<_>>()),
"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<SortOrderUpdate>=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::<Vec<_>>());config::save_passwords(&(),&cfg)?;Value::Null },
"reorder_ssh_keys" => { let _guard=state.mutation.lock().await;let updates:Vec<SortOrderUpdate>=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::<Vec<_>>());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::<Vec<_>>()) },
"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())},
@@ -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;
+48
View File
@@ -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<String>
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<SavedPassword> 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<SecretSortOrderUpdate>,
) -> AppResult<()> {
let mut cfg = config::load_passwords(&app)?;
config::reorder_passwords(
&mut cfg,
&updates
.into_iter()
.map(|update| (update.id, update.sort_order))
.collect::<Vec<_>>(),
);
config::save_passwords(&app, &cfg)?;
schedule_cloud_sync_notify(app);
Ok(())
}
#[tauri::command]
pub fn reorder_ssh_keys(
app: tauri::AppHandle,
updates: Vec<SecretSortOrderUpdate>,
) -> 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::<Vec<_>>(),
);
config::save_keys(&app, &cfg)?;
schedule_cloud_sync_notify(app);
Ok(())
}
+3
View File
@@ -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(),
+2
View File
@@ -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(),
+6
View File
@@ -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)?),
+2
View File
@@ -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,