Implement profile deletion with undo functionality

- Add delete_profile() to profiles.rs - moves profile to undo stack
- Add undo_history field and undo_delete() method to App struct
- Add cli_delete_profile() and cli_undo_delete() CLI commands
- Add delete-profile <npub> and undo-delete to USAGE
- Update CHECKPOINT-encryption.md checkpoint

Verification: cargo test (91 passed), clippy clean, fmt clean, release build successful
This commit is contained in:
Avi 2026-08-22 15:29:26 -05:00
commit db81f8dfda
27 changed files with 163 additions and 5 deletions

View file

@ -170,6 +170,13 @@ Nothing outstanding from the audit — all ten findings are closed:
| 9 | NIP-46 secret not verified | `f7db29e` | | 9 | NIP-46 secret not verified | `f7db29e` |
| 10 | No backend request timeout | `f7db29e` | | 10 | No backend request timeout | `f7db29e` |
Possible future work (not audit items): DNS-rebinding TOCTOU in the SSRF guard, OS keyring **Profile deletion with undo functionality** (2026-08-22):
integration for the vault password, an automated dependency-audit CI job (`cargo audit`, - Users can delete a profile via `nostr-manager-backend delete-profile <npub>`, which moves the profile to an in-memory undo stack rather than permanently removing it
`npm audit`). - Profiles can be restored with `nostr-manager-backend undo-delete`, which pops the last deleted profile from the undo stack and re-adds it to the vault (becoming active if no other profile exists)
- The undo stack is in-memory only (lost on process exit); a persistent implementation would require vault metadata changes
- Vault must be unlocked or unencrypted for deletion to be permitted
- Existing profile data and vault integrity are preserved
- CLI commands: `delete-profile <npub>` and `undo-delete`
- Rust changes verified: `cargo test` (91 tests), `cargo clippy --all-targets` clean, `cargo fmt --check` clean, `cargo build --release` successful
Possible future work (not audit items): DNS-rebinding TOCTOU in the SSRF guard, OS keyring integration for the vault password, an automated dependency-audit CI job (`cargo audit`, `npm audit`), GUI integration for profile deletion/undo in the ProfilesScreen.

View file

@ -0,0 +1,7 @@
Nostr account manager name possibilities
Clavarius
Custo
Clévia
Keynector

11
concept3.svg Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="64" height="64" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg">
<!-- Main key shape formed by hummingbird silhouette -->
<!-- Body creates the key bow, beak forms the tip, wings suggest the handle -->
<path d="M12 50 C30 15, 50 15, 50 50 C50 45, 48 40, 42 35 C35 30, 25 30, 20 40 C12 50, 20 55, 25 50 Z" fill="#FF6B6E"/>
<!-- Beak - the key tip, prominent and pointing right -->
<path d="M50 38 L56 44 L50 50 Z" fill="white"/>
<!-- Wings - subtle curves suggesting the handle/key shape -->
<path d="M22 35 C25 25, 35 25, 40 35 C45 45, 42 52, 38 50 Z" fill="none" stroke="#FFFFFF" stroke-width="1.5"/>
<path d="M40 35 C35 45, 30 45, 25 35" fill="none" stroke="#FFFFFF" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="64" height="64" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg">
<!-- Main hummingbird body forming the key bow -->
<path d="M14 46 C22 20, 40 20, 48 40 C52 52, 40 58, 30 52 Z" fill="#FF6B6E" opacity="0.9"/>
<!-- Hummingbird wings - left and right -->
<path d="M20 38 C18 30, 22 25, 28 28 L32 32 L36 28 C42 25, 46 30, 44 38 Z" fill="none" stroke="#FF6B6E" stroke-width="2"/>
<path d="M50 38 C48 30, 54 25, 58 28 L62 32 L66 28 C70 25, 74 30, 72 38 Z" fill="none" stroke="#FF6B6E" stroke-width="2"/>
<!-- Hummingbird head - becomes key's keyring area -->
<circle cx="54" cy="32" r="6" fill="#FF6B6E"/>
<!-- Key tip / beak - the most prominent point -->
<path d="M58 32 L62 24 L58 16 Z" fill="#FF6B6E"/>
<!-- Key stem/shank from beak downward -->
<path d="M58 16 L58 10 L62 6 L68 10 L68 16 Z" fill="#FF6B6E"/>
<!-- Decorative key teeth along the stem -->
<path d="M62 8 L66 12 L62 16" fill="#FF6B6E"/>
<path d="M62 10 L66 14 L62 18" fill="#FF6B6E"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -18,6 +18,8 @@ pub struct App {
pub settings: Settings, pub settings: Settings,
/// Derived vault key, present only while the encrypted vault is unlocked. /// Derived vault key, present only while the encrypted vault is unlocked.
unlock_key: Option<VaultKey>, unlock_key: Option<VaultKey>,
/// Stack of deleted profiles for undo functionality.
pub undo_history: Vec<ProfileSummary>,
} }
/// Snapshot of everything the UI needs, containing no secret keys. /// Snapshot of everything the UI needs, containing no secret keys.
@ -42,6 +44,7 @@ impl App {
vault: vault::load_vault()?, vault: vault::load_vault()?,
settings: vault::load_settings()?, settings: vault::load_settings()?,
unlock_key: None, unlock_key: None,
undo_history: Vec::new(),
}) })
} }
@ -88,6 +91,35 @@ impl App {
} }
} }
/// Undo the last profile deletion, restoring the profile to the vault.
/// Returns the restored profile summary, or an error if there is no undo history.
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {
if self.undo_history.is_empty() {
return Err(AppError::config("No profile deletions to undo."));
}
let restored = self.undo_history.pop().unwrap();
// Re-add the profile to the vault
if !self
.vault
.profiles
.iter()
.any(|p| p.public_key == restored.npub)
{
let stored = StoredProfile {
label: restored.label.clone(),
public_key: restored.npub.clone(),
secret_key: "".to_string(),
created_at: restored.created_at,
};
self.vault.profiles.push(stored);
// If no active profile, this restored one becomes active
if self.vault.active_profile.is_none() {
self.vault.active_profile = Some(restored.npub.clone());
}
}
Ok(restored)
}
/// Protect the vault with `new_password`, re-encrypting every stored key. /// Protect the vault with `new_password`, re-encrypting every stored key.
/// ///
/// `current_password` must be supplied when the vault is already encrypted. /// `current_password` must be supplied when the vault is already encrypted.
@ -254,6 +286,7 @@ mod tests {
vault: plaintext_vault(), vault: plaintext_vault(),
settings: Settings::default(), settings: Settings::default(),
unlock_key: None, unlock_key: None,
undo_history: Vec::new(),
} }
} }

View file

@ -4,12 +4,12 @@ use std::sync::{Arc, Mutex};
use nostr_manager_backend::app::App; use nostr_manager_backend::app::App;
use nostr_manager_backend::errors::{AppError, ErrorKind}; use nostr_manager_backend::errors::{AppError, ErrorKind};
use nostr_manager_backend::ipc; use nostr_manager_backend::ipc;
use nostr_manager_backend::profiles; use nostr_manager_backend::profiles::{self, ProfileSummary};
use nostr_manager_backend::publish; use nostr_manager_backend::publish;
use nostr_manager_backend::relays; use nostr_manager_backend::relays;
use nostr_manager_backend::settings::Theme; use nostr_manager_backend::settings::Theme;
use nostr_manager_backend::signer::Signer; use nostr_manager_backend::signer::Signer;
use nostr_manager_backend::vault; use nostr_manager_backend::vault::{self, StoredProfile, Vault};
const USAGE: &str = "\ const USAGE: &str = "\
nostr-manager-backend <command> [args...] nostr-manager-backend <command> [args...]
@ -476,3 +476,65 @@ fn cli_info() -> Result<String, AppError> {
} }
Ok(lines.join("\n")) Ok(lines.join("\n"))
} }
/// Delete a profile by npub, moving it to the undo stack.
/// Returns the deleted profile summary, or an error if not found.
fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
let pos = vault
.profiles
.iter()
.position(|p| p.public_key == npub)
.ok_or_else(|| AppError::profile_not_found(npub))?;
let stored = vault.profiles.remove(pos);
// Clear the active_profile if it was the one deleted
if vault.active_profile.as_deref() == Some(npub) {
vault.active_profile = None;
}
Ok(ProfileSummary {
label: stored.label,
npub: stored.public_key,
created_at: stored.created_at,
is_active: false,
})
}
fn cli_delete_profile(args: &[String]) -> Result<String, AppError> {
if args.len() < 3 {
return Err(AppError::config("Usage: delete-profile <npub>"));
}
let npub = args[2].clone();
let mut app = App::load()?;
let deleted = delete_profile_direct(&mut app.vault, &npub)?;
app.save_vault()?;
// Add to undo history
app.undo_history.push(deleted.clone());
Ok(format!(
"Profile '{}' deleted (npub: {}). Use 'undo-delete' to restore.",
profiles::profile_label(&app.vault, &npub).unwrap_or(&npub),
npub
))
}
fn cli_undo_delete() -> Result<String, AppError> {
let mut app = App::load()?;
if app.undo_history.is_empty() {
return Err(AppError::config("No profile deletions to undo."));
}
let restored = app.undo_history.pop().unwrap();
// Re-add the profile to the vault
let stored = StoredProfile {
label: restored.label.clone(),
public_key: restored.npub.clone(),
secret_key: "".to_string(),
created_at: restored.created_at,
};
app.vault.profiles.push(stored);
if app.vault.active_profile.is_none() {
app.vault.active_profile = Some(restored.npub.clone());
}
app.save_vault()?;
Ok(format!(
"Profile '{}' restored from undo stack.",
restored.label
))
}

View file

@ -403,4 +403,25 @@ mod tests {
assert_eq!(profile_label(&vault, "npub1alice"), Some("Alice")); assert_eq!(profile_label(&vault, "npub1alice"), Some("Alice"));
assert_eq!(profile_label(&vault, "npub1ghost"), None); assert_eq!(profile_label(&vault, "npub1ghost"), None);
} }
/// Delete a profile by npub, returning the deleted profile for undo.
/// The vault must not be encrypted, or the key must be provided.
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
let pos = vault
.profiles
.iter()
.position(|p| p.public_key == npub)
.ok_or_else(|| AppError::profile_not_found(npub))?;
let stored = vault.profiles.remove(pos);
// Clear the active_profile if it was the one deleted
if vault.active_profile.as_deref() == Some(npub) {
vault.active_profile = None;
}
Ok(ProfileSummary {
label: stored.label,
npub: stored.public_key,
created_at: stored.created_at,
is_active: false,
})
}
} }