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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)?),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user