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
|
|
@ -170,6 +170,13 @@ Nothing outstanding from the audit — all ten findings are closed:
|
|||
| 9 | NIP-46 secret not verified | `f7db29e` |
|
||||
| 10 | No backend request timeout | `f7db29e` |
|
||||
|
||||
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`).
|
||||
**Profile deletion with undo functionality** (2026-08-22):
|
||||
- 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
|
||||
- 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.
|
||||
|
|
|
|||
7
NostrAppNamePossibilities
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Nostr account manager name possibilities
|
||||
|
||||
|
||||
Clavarius
|
||||
Custo
|
||||
Clévia
|
||||
Keynector
|
||||
11
concept3.svg
Normal 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 |
BIN
frontend/assets/icons/dark-icon-128.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/assets/icons/dark-icon-16.png
Normal file
|
After Width: | Height: | Size: 688 B |
BIN
frontend/assets/icons/dark-icon-32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/assets/icons/dark-icon-48.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/assets/icons/dark-icon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/assets/icons/hummingbird-key-128.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/assets/icons/hummingbird-key-16.png
Normal file
|
After Width: | Height: | Size: 688 B |
BIN
frontend/assets/icons/hummingbird-key-256.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/assets/icons/hummingbird-key-32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/assets/icons/hummingbird-key-48.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
17
frontend/assets/icons/hummingbird-key.svg
Normal 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 |
BIN
frontend/assets/icons/light-icon-128.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/assets/icons/light-icon-16.png
Normal file
|
After Width: | Height: | Size: 688 B |
BIN
frontend/assets/icons/light-icon-32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/assets/icons/light-icon-48.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/assets/icons/light-icon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/assets/icons/transparent-icon-128.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/assets/icons/transparent-icon-16.png
Normal file
|
After Width: | Height: | Size: 688 B |
BIN
frontend/assets/icons/transparent-icon-32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/assets/icons/transparent-icon-48.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/assets/icons/transparent-icon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
33
src/app.rs
|
|
@ -18,6 +18,8 @@ pub struct App {
|
|||
pub settings: Settings,
|
||||
/// Derived vault key, present only while the encrypted vault is unlocked.
|
||||
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.
|
||||
|
|
@ -42,6 +44,7 @@ impl App {
|
|||
vault: vault::load_vault()?,
|
||||
settings: vault::load_settings()?,
|
||||
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.
|
||||
///
|
||||
/// `current_password` must be supplied when the vault is already encrypted.
|
||||
|
|
@ -254,6 +286,7 @@ mod tests {
|
|||
vault: plaintext_vault(),
|
||||
settings: Settings::default(),
|
||||
unlock_key: None,
|
||||
undo_history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
66
src/main.rs
|
|
@ -4,12 +4,12 @@ use std::sync::{Arc, Mutex};
|
|||
use nostr_manager_backend::app::App;
|
||||
use nostr_manager_backend::errors::{AppError, ErrorKind};
|
||||
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::relays;
|
||||
use nostr_manager_backend::settings::Theme;
|
||||
use nostr_manager_backend::signer::Signer;
|
||||
use nostr_manager_backend::vault;
|
||||
use nostr_manager_backend::vault::{self, StoredProfile, Vault};
|
||||
|
||||
const USAGE: &str = "\
|
||||
nostr-manager-backend <command> [args...]
|
||||
|
|
@ -476,3 +476,65 @@ fn cli_info() -> Result<String, AppError> {
|
|||
}
|
||||
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
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -403,4 +403,25 @@ mod tests {
|
|||
assert_eq!(profile_label(&vault, "npub1alice"), Some("Alice"));
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||