diff --git a/src/PassKey.Core/Models/CreditCardEntry.cs b/src/PassKey.Core/Models/CreditCardEntry.cs
index c06b726..15aede8 100644
--- a/src/PassKey.Core/Models/CreditCardEntry.cs
+++ b/src/PassKey.Core/Models/CreditCardEntry.cs
@@ -7,7 +7,7 @@ namespace PassKey.Core.Models;
/// Fields follow the physical card flow: Number → Name → Expiry → CVV → metadata.
/// Stored as part of the encrypted vault blob in the VaultData SQLite table.
///
-public sealed class CreditCardEntry
+public sealed class CreditCardEntry : IVaultEntry
{
/// Gets or sets the unique identifier for this entry.
public Guid Id { get; set; } = Guid.NewGuid();
diff --git a/src/PassKey.Core/Models/IVaultEntry.cs b/src/PassKey.Core/Models/IVaultEntry.cs
new file mode 100644
index 0000000..388d4cb
--- /dev/null
+++ b/src/PassKey.Core/Models/IVaultEntry.cs
@@ -0,0 +1,15 @@
+namespace PassKey.Core.Models;
+
+///
+/// Common contract for all vault entry types (passwords, credit cards, identities, secure notes).
+/// Used by generic ViewModels and services that need to operate uniformly across entry types
+/// without coupling to concrete model classes.
+///
+public interface IVaultEntry
+{
+ /// The unique identifier for this entry (assigned on creation).
+ Guid Id { get; }
+
+ /// The UTC timestamp of the last modification to this entry.
+ DateTime ModifiedAt { get; set; }
+}
diff --git a/src/PassKey.Core/Models/IdentityEntry.cs b/src/PassKey.Core/Models/IdentityEntry.cs
index db371ac..b0b5a98 100644
--- a/src/PassKey.Core/Models/IdentityEntry.cs
+++ b/src/PassKey.Core/Models/IdentityEntry.cs
@@ -5,7 +5,7 @@ namespace PassKey.Core.Models;
/// Stores contact information, postal address, and government-issued document numbers.
/// Stored as part of the encrypted vault blob in the VaultData SQLite table.
///
-public sealed class IdentityEntry
+public sealed class IdentityEntry : IVaultEntry
{
/// Gets or sets the unique identifier for this entry.
public Guid Id { get; set; } = Guid.NewGuid();
diff --git a/src/PassKey.Core/Models/PasswordEntry.cs b/src/PassKey.Core/Models/PasswordEntry.cs
index d90486b..b526ff2 100644
--- a/src/PassKey.Core/Models/PasswordEntry.cs
+++ b/src/PassKey.Core/Models/PasswordEntry.cs
@@ -5,7 +5,7 @@ namespace PassKey.Core.Models;
/// Instances are serialized to JSON, encrypted with AES-GCM, and stored as a
/// single encrypted blob in the VaultData SQLite table.
///
-public sealed class PasswordEntry
+public sealed class PasswordEntry : IVaultEntry
{
/// Gets or sets the unique identifier for this entry.
public Guid Id { get; set; } = Guid.NewGuid();
diff --git a/src/PassKey.Core/Models/SecureNoteEntry.cs b/src/PassKey.Core/Models/SecureNoteEntry.cs
index 0ae1c24..6b3ff8e 100644
--- a/src/PassKey.Core/Models/SecureNoteEntry.cs
+++ b/src/PassKey.Core/Models/SecureNoteEntry.cs
@@ -6,7 +6,7 @@ namespace PassKey.Core.Models;
/// Represents an encrypted free-text note in the PassKey vault.
/// Stored as part of the encrypted vault blob in the VaultData SQLite table.
///
-public sealed class SecureNoteEntry
+public sealed class SecureNoteEntry : IVaultEntry
{
/// Gets or sets the unique identifier for this note.
public Guid Id { get; set; } = Guid.NewGuid();
diff --git a/src/PassKey.Core/Services/CardTypeDetector.cs b/src/PassKey.Core/Services/CardTypeDetector.cs
index 640b34f..cc3c67e 100644
--- a/src/PassKey.Core/Services/CardTypeDetector.cs
+++ b/src/PassKey.Core/Services/CardTypeDetector.cs
@@ -12,6 +12,8 @@ private static class BinRanges
{
// MasterCard: standard range 51-55 is checked inline with a 2-digit prefix;
// the expanded range covers re-issued BINs introduced in 2017.
+ internal const int MasterCardStandardStart = 51;
+ internal const int MasterCardStandardEnd = 55;
internal const int MasterCardExpandedStart = 2221;
internal const int MasterCardExpandedEnd = 2720;
@@ -32,6 +34,25 @@ private static class BinRanges
internal const int DinersRange3End = 305;
}
+ /// Network-specific literal prefixes used for card-type detection (string-startswith match).
+ private static class BinPrefixes
+ {
+ /// American Express: 34 or 37.
+ internal static readonly string[] Amex = ["34", "37"];
+
+ /// Visa always starts with 4.
+ internal const string Visa = "4";
+
+ /// Discover: 6011 or 65 (in addition to the 3- and 6-digit ranges in ).
+ internal static readonly string[] Discover = ["6011", "65"];
+
+ /// Diners Club: 36 or 38 (in addition to the 300-305 range in ).
+ internal static readonly string[] Diners = ["36", "38"];
+
+ /// Maestro: 5018, 5020, 5038, 6304, 6759, 6761, 6762, 6763.
+ internal static readonly string[] Maestro = ["5018", "5020", "5038", "6304", "6759", "6761", "6762", "6763"];
+ }
+
///
/// Detects the card type from the card number using BIN prefix tables.
///
@@ -44,19 +65,19 @@ public static CardType Detect(string cardNumber)
if (digits.Length < 4)
return CardType.Unknown;
- // Amex: starts with 34 or 37
- if (digits.StartsWith("34") || digits.StartsWith("37"))
+ // Amex: starts with 34 or 37.
+ if (StartsWithAny(digits, BinPrefixes.Amex))
return CardType.Amex;
- // Visa: starts with 4
- if (digits.StartsWith('4'))
+ // Visa: starts with 4.
+ if (digits.StartsWith(BinPrefixes.Visa))
return CardType.Visa;
- // MasterCard: 51-55 or expanded range 2221-2720
+ // MasterCard: 51-55 or expanded range 2221-2720.
if (digits.Length >= 2)
{
var prefix2 = int.Parse(digits[..2]);
- if (prefix2 >= 51 && prefix2 <= 55)
+ if (prefix2 >= BinRanges.MasterCardStandardStart && prefix2 <= BinRanges.MasterCardStandardEnd)
return CardType.MasterCard;
}
if (digits.Length >= 4)
@@ -66,8 +87,8 @@ public static CardType Detect(string cardNumber)
return CardType.MasterCard;
}
- // Discover: 6011, 622126-622925, 644-649, 65
- if (digits.StartsWith("6011") || digits.StartsWith("65"))
+ // Discover: 6011, 622126-622925, 644-649, 65.
+ if (StartsWithAny(digits, BinPrefixes.Discover))
return CardType.Discover;
if (digits.Length >= 3)
{
@@ -82,7 +103,7 @@ public static CardType Detect(string cardNumber)
return CardType.Discover;
}
- // JCB: 3528-3589
+ // JCB: 3528-3589.
if (digits.Length >= 4)
{
var prefix4 = int.Parse(digits[..4]);
@@ -90,8 +111,8 @@ public static CardType Detect(string cardNumber)
return CardType.JCB;
}
- // Diners Club: 300-305, 36, 38
- if (digits.StartsWith("36") || digits.StartsWith("38"))
+ // Diners Club: 300-305, 36, 38.
+ if (StartsWithAny(digits, BinPrefixes.Diners))
return CardType.DinersClub;
if (digits.Length >= 3)
{
@@ -100,14 +121,23 @@ public static CardType Detect(string cardNumber)
return CardType.DinersClub;
}
- // Maestro: 5018, 5020, 5038, 6304, 6759, 6761, 6762, 6763
- string[] maestroPrefixes = ["5018", "5020", "5038", "6304", "6759", "6761", "6762", "6763"];
- if (digits.Length >= 4 && maestroPrefixes.Any(p => digits.StartsWith(p)))
+ // Maestro: 5018, 5020, 5038, 6304, 6759, 6761, 6762, 6763.
+ if (digits.Length >= 4 && StartsWithAny(digits, BinPrefixes.Maestro))
return CardType.Maestro;
return CardType.Unknown;
}
+ /// Returns true if the supplied digit string starts with any of the supplied prefixes.
+ private static bool StartsWithAny(string digits, string[] prefixes)
+ {
+ foreach (var prefix in prefixes)
+ {
+ if (digits.StartsWith(prefix)) return true;
+ }
+ return false;
+ }
+
///
/// Validates a card number using the Luhn algorithm.
///
diff --git a/src/PassKey.Core/Services/CsvImporter.cs b/src/PassKey.Core/Services/CsvImporter.cs
index a6044ea..80be7ae 100644
--- a/src/PassKey.Core/Services/CsvImporter.cs
+++ b/src/PassKey.Core/Services/CsvImporter.cs
@@ -18,11 +18,26 @@ public Vault ParseCsv(string csvContent)
if (columnMap.Count == 0) return vault;
+ // NordPass and a few other exporters include a "type" column distinguishing
+ // password / credit_card / identity / note rows in a single CSV. When present,
+ // only "password" (or unrecognised/empty types) are imported here as
+ // PasswordEntries; richer cross-type import is tracked for a future release.
+ var typeColumnIndex = TryFindTypeColumn(headers);
+
for (int i = 1; i < lines.Length; i++)
{
var fields = ParseCsvLine(lines[i]);
if (fields.Count == 0) continue;
+ // Skip non-password rows when a "type" column is present so card/identity/note
+ // labels don't pollute the imported password list.
+ if (typeColumnIndex >= 0 && typeColumnIndex < fields.Count)
+ {
+ var rowType = fields[typeColumnIndex].Trim().ToLowerInvariant();
+ if (rowType.Length > 0 && rowType != "password" && rowType != "login")
+ continue;
+ }
+
var entry = new PasswordEntry
{
Id = Guid.NewGuid(),
@@ -71,7 +86,8 @@ private static Dictionary MapHeaders(List headers)
case "url" or "uri" or "website" or "login_uri":
map.TryAdd(ColumnType.Url, i);
break;
- case "notes" or "comment" or "comments" or "extra":
+ case "notes" or "note" or "comment" or "comments" or "extra":
+ // "note" (singular) is required for NordPass exports.
map.TryAdd(ColumnType.Notes, i);
break;
}
@@ -87,6 +103,21 @@ private static string GetField(List fields, Dictionary
return fields[idx].Trim();
}
+ ///
+ /// Returns the zero-based index of a "type"-style column in the header row, or -1 if absent.
+ /// Used to detect mixed-format exports (e.g. NordPass) so non-password rows can be skipped.
+ ///
+ private static int TryFindTypeColumn(List headers)
+ {
+ for (int i = 0; i < headers.Count; i++)
+ {
+ var name = headers[i].Trim().ToLowerInvariant();
+ if (name == "type" || name == "item_type" || name == "entry_type")
+ return i;
+ }
+ return -1;
+ }
+
///
/// Parses a CSV line handling RFC 4180 quoted fields.
///
diff --git a/src/PassKey.Desktop/MainWindow.xaml.cs b/src/PassKey.Desktop/MainWindow.xaml.cs
index 8a00403..9833409 100644
--- a/src/PassKey.Desktop/MainWindow.xaml.cs
+++ b/src/PassKey.Desktop/MainWindow.xaml.cs
@@ -89,6 +89,13 @@ private async void OnActivated(object sender, WindowActivatedEventArgs args)
// Apply saved theme on startup
ApplySavedTheme();
+ // Register a lazy XamlRoot accessor with the dialog service so ViewModels can show
+ // ContentDialogs without each having to plumb a XamlRoot reference. A delegate is
+ // used (rather than caching Content.XamlRoot directly) because that property can be
+ // null until the window has rendered; reading it just-in-time, when each dialog is
+ // about to be shown, is always safe.
+ App.Services.GetRequiredService().XamlRootAccessor = () => Content?.XamlRoot;
+
try
{
await _mainViewModel.InitializeAsync();
diff --git a/src/PassKey.Desktop/Services/DialogQueueService.cs b/src/PassKey.Desktop/Services/DialogQueueService.cs
index d4ebcea..54a03c4 100644
--- a/src/PassKey.Desktop/Services/DialogQueueService.cs
+++ b/src/PassKey.Desktop/Services/DialogQueueService.cs
@@ -1,3 +1,4 @@
+using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace PassKey.Desktop.Services;
@@ -22,6 +23,35 @@ public sealed class DialogQueueService : IDialogQueueService
private readonly Queue> _queue = new();
private bool _isPumping;
+ ///
+ public Func? XamlRootAccessor { get; set; }
+
+ ///
+ public Task ConfirmAsync(
+ string title,
+ string content,
+ string primaryButtonText,
+ string closeButtonText,
+ ContentDialogButton defaultButton = ContentDialogButton.Close)
+ {
+ return EnqueueAndWait(() =>
+ {
+ // Resolve the XamlRoot lazily — on the UI thread, at the exact moment the dialog
+ // is about to be shown — so the value is guaranteed to be non-null by the time the
+ // window has rendered and the user has triggered a delete (or similar) action.
+ var dialog = new ContentDialog
+ {
+ Title = title,
+ Content = content,
+ PrimaryButtonText = primaryButtonText,
+ CloseButtonText = closeButtonText,
+ DefaultButton = defaultButton,
+ XamlRoot = XamlRootAccessor?.Invoke(),
+ };
+ return dialog.ShowAsync().AsTask();
+ }).ContinueWith(t => t.Result == ContentDialogResult.Primary, TaskScheduler.Default);
+ }
+
///
/// Enqueues a dialog factory for fire-and-forget display. The result is discarded.
/// Use if you need the .
diff --git a/src/PassKey.Desktop/Services/IDialogQueueService.cs b/src/PassKey.Desktop/Services/IDialogQueueService.cs
index 036f6f3..ec87bd8 100644
--- a/src/PassKey.Desktop/Services/IDialogQueueService.cs
+++ b/src/PassKey.Desktop/Services/IDialogQueueService.cs
@@ -1,3 +1,4 @@
+using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace PassKey.Desktop.Services;
@@ -12,9 +13,33 @@ namespace PassKey.Desktop.Services;
/// A is intentionally avoided here because
/// awaiting it on the UI thread causes a deadlock; use a
/// with a serial pump instead.
+///
+///
+/// XamlRoot: WinUI 3 desktop apps require every to have
+/// its assigned before ShowAsync is called;
+/// otherwise ("This element does not have a XamlRoot")
+/// is raised. Code-behind handlers can use this.XamlRoot, but ViewModels lack a
+/// natural reference. To remove that duplication, the service exposes
+/// — set once at startup from the main window — and
+/// which applies it automatically.
+///
///
public interface IDialogQueueService
{
+ ///
+ /// Gets or sets the just-in-time accessor used by to resolve
+ /// the host at the moment the dialog is shown.
+ ///
+ ///
+ /// A direct value cannot be cached at startup because
+ /// . can be
+ /// until the window is fully loaded. The accessor pattern instead reads the root lazily on
+ /// the UI thread when each dialog is actually displayed, by which point the value is valid.
+ /// Set this once during application startup, e.g.
+ /// XamlRootAccessor = () => mainWindow.Content?.XamlRoot;.
+ ///
+ Func? XamlRootAccessor { get; set; }
+
///
/// Enqueues a dialog factory for fire-and-forget execution.
/// The result is discarded; use when the result is needed.
@@ -29,4 +54,23 @@ public interface IDialogQueueService
/// A factory that creates and shows the .
/// The selected by the user.
Task EnqueueAndWait(Func> dialogFactory);
+
+ ///
+ /// Shows a standard confirmation dialog with the supplied strings and waits for the user's choice.
+ /// The dialog's is set automatically from
+ /// , removing a common source of
+ /// crashes when called from ViewModels.
+ ///
+ /// Localised dialog title.
+ /// Localised dialog body text.
+ /// Localised primary action label (returned as ).
+ /// Localised cancel/close label (returned as ).
+ /// Which button is highlighted by default (defaults to the close/cancel button).
+ /// if the user selected the primary button; for close.
+ Task ConfirmAsync(
+ string title,
+ string content,
+ string primaryButtonText,
+ string closeButtonText,
+ ContentDialogButton defaultButton = ContentDialogButton.Close);
}
diff --git a/src/PassKey.Desktop/ViewModels/Base/BaseDetailViewModel.cs b/src/PassKey.Desktop/ViewModels/Base/BaseDetailViewModel.cs
new file mode 100644
index 0000000..4ae1601
--- /dev/null
+++ b/src/PassKey.Desktop/ViewModels/Base/BaseDetailViewModel.cs
@@ -0,0 +1,218 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PassKey.Core.Models;
+using PassKey.Desktop.Services;
+
+namespace PassKey.Desktop.ViewModels.Base;
+
+///
+/// Abstract base for the four "Detail" ViewModels (Password, CreditCard, Identity, SecureNote).
+/// Encapsulates the shared add/edit/save/delete workflow via a template-method pattern,
+/// removing ~200 lines of duplicated state and command logic across concrete subclasses.
+///
+/// A concrete vault entry type implementing .
+///
+/// Lifecycle:
+///
+/// - resets state and prepares the panel for a new entry creation.
+/// - loads an existing entry into the editor.
+/// - creates or applies changes to the entry; raises .
+/// - confirms with the user and removes the entry; raises .
+///
+/// Subclasses must implement the protected abstract hooks that handle type-specific
+/// concerns (field reset/load/apply, vault collection accessor, dialog strings, validation).
+/// They may additionally override /
+/// to perform extra work after a successful save
+/// (e.g., SecureNoteDetailViewModel transitions to edit mode in-place after creation).
+///
+public abstract partial class BaseDetailViewModel : ObservableObject
+ where TEntry : class, IVaultEntry
+{
+ /// Vault state service for accessing the in-memory unlocked vault.
+ protected readonly IVaultStateService VaultState;
+
+ /// Dialog queue used to serialize instances.
+ protected readonly IDialogQueueService DialogQueue;
+
+ /// The entry currently being edited, or when creating a new entry.
+ protected TEntry? EditingEntry;
+
+ private bool _isNew;
+
+ /// Indicates whether the panel is currently in "create new" mode (vs editing an existing entry).
+ public bool IsNew => _isNew;
+
+ /// Localized title displayed at the top of the editor panel ("Aggiungi…" or "Modifica…").
+ [ObservableProperty]
+ public partial string PanelTitle { get; set; } = string.Empty;
+
+ /// Whether the current field values satisfy the type-specific validation rules.
+ [ObservableProperty]
+ public partial bool CanSave { get; set; }
+
+ /// Indicates that a save operation is in progress (used to disable UI / show spinner).
+ [ObservableProperty]
+ public partial bool IsSaving { get; set; }
+
+ /// Raised after a successful save. Parameters: wasNew and the entry's .
+ public Action? Saved { get; set; }
+
+ /// Raised after the entry has been deleted from the vault. Parameter: deleted entry id.
+ public Action? Deleted { get; set; }
+
+ /// Raised when the user clicks the cancel button on the editor panel.
+ public Action? Cancelled { get; set; }
+
+ /// Initialises shared dependencies. Subclasses pass these through their own DI constructor.
+ protected BaseDetailViewModel(IVaultStateService vaultState, IDialogQueueService dialogQueue)
+ {
+ VaultState = vaultState;
+ DialogQueue = dialogQueue;
+ }
+
+ // ─── Template-method hooks (subclass implementations) ─────────────────────
+
+ /// Reset all type-specific observable properties to their defaults for a new entry.
+ protected abstract void ResetFieldsForNew();
+
+ /// Populate observable properties from the supplied entry (called by ).
+ protected abstract void LoadFromEntry(TEntry entry);
+
+ /// Construct a brand-new from the current observable property values.
+ protected abstract TEntry CreateNewEntry();
+
+ /// Copy current observable property values onto the supplied existing entry (save edit).
+ protected abstract void ApplyToEntry(TEntry entry);
+
+ /// Return the vault's typed collection where this entry kind lives (Passwords / CreditCards / …).
+ protected abstract IList GetVaultCollection(Vault vault);
+
+ /// Recompute based on the type-specific required-field validation.
+ protected abstract void UpdateCanSave();
+
+ /// Localised panel title used when creating a new entry (e.g., "Aggiungi password").
+ protected abstract string GetPanelTitleForNew();
+
+ /// Localised panel title used when editing an existing entry (e.g., "Modifica password").
+ protected abstract string GetPanelTitleForEdit();
+
+ /// Localised title shown in the delete-confirmation dialog (e.g., "Elimina password").
+ protected abstract string GetDeleteDialogTitle();
+
+ /// Best-effort display name used inside the delete-confirmation dialog body for the supplied entry.
+ protected abstract string GetDeleteDisplayName(TEntry entry);
+
+ /// Localised body template for the delete dialog. Default mirrors the original copy across all four VMs.
+ protected virtual string GetDeleteDialogContent(string displayName)
+ => $"Eliminare \"{displayName}\"?\nQuesta azione è irreversibile.";
+
+ /// Localised primary-button text on the delete dialog.
+ protected virtual string GetDeletePrimaryButtonText() => "Elimina";
+
+ /// Localised close-button text on the delete dialog.
+ protected virtual string GetDeleteCloseButtonText() => "Annulla";
+
+ /// Hook invoked after a successful save of a new entry (default: no-op).
+ /// Subclasses can override to transition the panel state in-place (e.g., notes editor).
+ protected virtual void OnSavedNew(TEntry entry) { }
+
+ /// Hook invoked after a successful save of an edited entry (default: no-op).
+ /// Subclasses can use this to refresh internal "original" snapshots, etc.
+ protected virtual void OnSavedEdit(TEntry entry) { }
+
+ // ─── Public API ───────────────────────────────────────────────────────────
+
+ /// Prepare the panel for adding a brand-new entry.
+ public virtual void StartNew()
+ {
+ EditingEntry = null;
+ _isNew = true;
+ PanelTitle = GetPanelTitleForNew();
+ ResetFieldsForNew();
+ UpdateCanSave();
+ }
+
+ /// Prepare the panel for editing the supplied existing entry.
+ public virtual void StartEdit(TEntry entry)
+ {
+ EditingEntry = entry;
+ _isNew = false;
+ PanelTitle = GetPanelTitleForEdit();
+ LoadFromEntry(entry);
+ UpdateCanSave();
+ }
+
+ /// Persist the new or edited entry into the in-memory vault; the actual disk write is the caller's job.
+ [RelayCommand]
+ protected virtual Task SaveAsync()
+ {
+ if (!CanSave) return Task.CompletedTask;
+
+ var vault = VaultState.CurrentVault;
+ if (vault is null) return Task.CompletedTask;
+
+ IsSaving = true;
+
+ try
+ {
+ bool wasNew = _isNew;
+ Guid entryId;
+
+ if (_isNew)
+ {
+ var entry = CreateNewEntry();
+ GetVaultCollection(vault).Add(entry);
+ entryId = entry.Id;
+ OnSavedNew(entry);
+ }
+ else if (EditingEntry is not null)
+ {
+ ApplyToEntry(EditingEntry);
+ EditingEntry.ModifiedAt = DateTime.UtcNow;
+ entryId = EditingEntry.Id;
+ OnSavedEdit(EditingEntry);
+ }
+ else
+ {
+ return Task.CompletedTask;
+ }
+
+ Saved?.Invoke(wasNew, entryId);
+ }
+ finally
+ {
+ IsSaving = false;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ /// Ask the user to confirm and then remove the current entry from the vault.
+ [RelayCommand]
+ protected virtual async Task DeleteAsync()
+ {
+ if (EditingEntry is null || _isNew) return;
+
+ var confirmed = await DialogQueue.ConfirmAsync(
+ title: GetDeleteDialogTitle(),
+ content: GetDeleteDialogContent(GetDeleteDisplayName(EditingEntry)),
+ primaryButtonText: GetDeletePrimaryButtonText(),
+ closeButtonText: GetDeleteCloseButtonText());
+
+ if (confirmed)
+ {
+ var vault = VaultState.CurrentVault;
+ var entryId = EditingEntry.Id;
+ if (vault is not null)
+ {
+ GetVaultCollection(vault).Remove(EditingEntry);
+ }
+ Deleted?.Invoke(entryId);
+ }
+ }
+
+ // ─── Helpers for subclass state transitions (used by SecureNote) ──────────
+
+ /// Subclass-accessible mutator for the "is new" flag (used by special-case state transitions).
+ protected void SetIsNew(bool value) => _isNew = value;
+}
diff --git a/src/PassKey.Desktop/ViewModels/CreditCardDetailViewModel.cs b/src/PassKey.Desktop/ViewModels/CreditCardDetailViewModel.cs
index 5b0c402..c99cb64 100644
--- a/src/PassKey.Desktop/ViewModels/CreditCardDetailViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/CreditCardDetailViewModel.cs
@@ -1,29 +1,19 @@
using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
using PassKey.Core.Constants;
using PassKey.Core.Models;
using PassKey.Core.Services;
using PassKey.Desktop.Services;
+using PassKey.Desktop.ViewModels.Base;
namespace PassKey.Desktop.ViewModels;
///
/// Credit card detail ViewModel for add/edit panel.
/// Fields follow physical card flow: Number → Cardholder → Expiry → CVV → PIN → Label → Category → Color → Notes.
+/// Shared add/edit/save/delete plumbing is provided by .
///
-public partial class CreditCardDetailViewModel : ObservableObject
+public partial class CreditCardDetailViewModel : BaseDetailViewModel
{
- private readonly IVaultStateService _vaultState;
- private readonly IDialogQueueService _dialogQueue;
-
- private CreditCardEntry? _editingEntry;
- private bool _isNew;
-
- public bool IsNew => _isNew;
-
- [ObservableProperty]
- public partial string PanelTitle { get; set; } = string.Empty;
-
[ObservableProperty]
public partial string CardNumber { get; set; } = string.Empty;
@@ -63,34 +53,25 @@ public partial class CreditCardDetailViewModel : ObservableObject
[ObservableProperty]
public partial string FormattedCardNumber { get; set; } = string.Empty;
- [ObservableProperty]
- public partial bool CanSave { get; set; }
-
- [ObservableProperty]
- public partial bool IsSaving { get; set; }
-
- /// Callback when save completes: (isNew, entryId).
- public Action? Saved { get; set; }
-
- /// Callback when delete completes: (entryId).
- public Action? Deleted { get; set; }
-
- /// Callback when cancel is clicked.
- public Action? Cancelled { get; set; }
-
public CreditCardDetailViewModel(
IVaultStateService vaultState,
IDialogQueueService dialogQueue)
+ : base(vaultState, dialogQueue)
{
- _vaultState = vaultState;
- _dialogQueue = dialogQueue;
}
- public void StartNew()
+ // ─── Template-method overrides ────────────────────────────────────────────
+
+ protected override string GetPanelTitleForNew() => "Aggiungi carta";
+ protected override string GetPanelTitleForEdit() => "Modifica carta";
+ protected override string GetDeleteDialogTitle() => "Elimina carta";
+ protected override string GetDeleteDisplayName(CreditCardEntry entry)
+ => entry.Label is { Length: > 0 } l ? l : "Carta senza nome";
+
+ protected override IList GetVaultCollection(Vault vault) => vault.CreditCards;
+
+ protected override void ResetFieldsForNew()
{
- _editingEntry = null;
- _isNew = true;
- PanelTitle = "Aggiungi carta";
CardNumber = string.Empty;
CardholderName = string.Empty;
ExpiryMonth = DateTime.Now.Month;
@@ -104,14 +85,10 @@ public void StartNew()
DetectedCardType = CardType.Unknown;
IsLuhnValid = false;
FormattedCardNumber = string.Empty;
- UpdateCanSave();
}
- public void StartEdit(CreditCardEntry entry)
+ protected override void LoadFromEntry(CreditCardEntry entry)
{
- _editingEntry = entry;
- _isNew = false;
- PanelTitle = "Modifica carta";
CardNumber = entry.CardNumber;
CardholderName = entry.CardholderName;
ExpiryMonth = entry.ExpiryMonth;
@@ -124,28 +101,39 @@ public void StartEdit(CreditCardEntry entry)
Notes = entry.Notes;
DetectedCardType = entry.CardType;
UpdateCardNumberDisplay();
- UpdateCanSave();
}
- partial void OnCardNumberChanged(string value)
+ protected override CreditCardEntry CreateNewEntry() => new()
{
- DetectedCardType = CardTypeDetector.Detect(value);
- IsLuhnValid = CardTypeDetector.ValidateLuhn(value);
- UpdateCardNumberDisplay();
- UpdateCanSave();
- }
-
- partial void OnCardholderNameChanged(string value) => UpdateCanSave();
- partial void OnExpiryMonthChanged(int value) => UpdateCanSave();
- partial void OnExpiryYearChanged(int value) => UpdateCanSave();
- partial void OnCvvChanged(string value) => UpdateCanSave();
-
- private void UpdateCardNumberDisplay()
+ CardNumber = CardNumber.Trim(),
+ CardholderName = CardholderName.Trim(),
+ ExpiryMonth = ExpiryMonth,
+ ExpiryYear = ExpiryYear,
+ Cvv = Cvv.Trim(),
+ Pin = Pin.Trim(),
+ Label = Label.Trim(),
+ Category = Category,
+ AccentColor = AccentColor,
+ CardType = DetectedCardType,
+ Notes = Notes.Trim()
+ };
+
+ protected override void ApplyToEntry(CreditCardEntry entry)
{
- FormattedCardNumber = CardTypeDetector.FormatCardNumber(CardNumber, DetectedCardType);
+ entry.CardNumber = CardNumber.Trim();
+ entry.CardholderName = CardholderName.Trim();
+ entry.ExpiryMonth = ExpiryMonth;
+ entry.ExpiryYear = ExpiryYear;
+ entry.Cvv = Cvv.Trim();
+ entry.Pin = Pin.Trim();
+ entry.Label = Label.Trim();
+ entry.Category = Category;
+ entry.AccentColor = AccentColor;
+ entry.CardType = DetectedCardType;
+ entry.Notes = Notes.Trim();
}
- private void UpdateCanSave()
+ protected override void UpdateCanSave()
{
CanSave = !string.IsNullOrWhiteSpace(CardNumber) &&
!string.IsNullOrWhiteSpace(CardholderName) &&
@@ -154,95 +142,23 @@ private void UpdateCanSave()
!string.IsNullOrWhiteSpace(Cvv);
}
- [RelayCommand]
- private Task SaveAsync()
+ // ─── Property change handlers ─────────────────────────────────────────────
+
+ partial void OnCardNumberChanged(string value)
{
- if (!CanSave) return Task.CompletedTask;
-
- var vault = _vaultState.CurrentVault;
- if (vault is null) return Task.CompletedTask;
-
- IsSaving = true;
-
- try
- {
- bool wasNew = _isNew;
- Guid entryId;
-
- if (_isNew)
- {
- var entry = new CreditCardEntry
- {
- CardNumber = CardNumber.Trim(),
- CardholderName = CardholderName.Trim(),
- ExpiryMonth = ExpiryMonth,
- ExpiryYear = ExpiryYear,
- Cvv = Cvv.Trim(),
- Pin = Pin.Trim(),
- Label = Label.Trim(),
- Category = Category,
- AccentColor = AccentColor,
- CardType = DetectedCardType,
- Notes = Notes.Trim()
- };
- vault.CreditCards.Add(entry);
- entryId = entry.Id;
- }
- else if (_editingEntry is not null)
- {
- _editingEntry.CardNumber = CardNumber.Trim();
- _editingEntry.CardholderName = CardholderName.Trim();
- _editingEntry.ExpiryMonth = ExpiryMonth;
- _editingEntry.ExpiryYear = ExpiryYear;
- _editingEntry.Cvv = Cvv.Trim();
- _editingEntry.Pin = Pin.Trim();
- _editingEntry.Label = Label.Trim();
- _editingEntry.Category = Category;
- _editingEntry.AccentColor = AccentColor;
- _editingEntry.CardType = DetectedCardType;
- _editingEntry.Notes = Notes.Trim();
- _editingEntry.ModifiedAt = DateTime.UtcNow;
- entryId = _editingEntry.Id;
- }
- else
- {
- return Task.CompletedTask;
- }
-
- Saved?.Invoke(wasNew, entryId);
- }
- finally
- {
- IsSaving = false;
- }
-
- return Task.CompletedTask;
+ DetectedCardType = CardTypeDetector.Detect(value);
+ IsLuhnValid = CardTypeDetector.ValidateLuhn(value);
+ UpdateCardNumberDisplay();
+ UpdateCanSave();
}
- [RelayCommand]
- private async Task DeleteAsync()
+ partial void OnCardholderNameChanged(string value) => UpdateCanSave();
+ partial void OnExpiryMonthChanged(int value) => UpdateCanSave();
+ partial void OnExpiryYearChanged(int value) => UpdateCanSave();
+ partial void OnCvvChanged(string value) => UpdateCanSave();
+
+ private void UpdateCardNumberDisplay()
{
- if (_editingEntry is null || _isNew) return;
-
- var result = await _dialogQueue.EnqueueAndWait(() =>
- {
- var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
- {
- Title = "Elimina carta",
- Content = $"Eliminare \"{(_editingEntry.Label is { Length: > 0 } l ? l : "Carta senza nome")}\"?\nQuesta azione è irreversibile.",
- PrimaryButtonText = "Elimina",
- CloseButtonText = "Annulla",
- DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close
- };
- return dialog.ShowAsync().AsTask();
- });
-
- if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
- {
- var vault = _vaultState.CurrentVault;
- var entryId = _editingEntry.Id;
- vault?.CreditCards.Remove(_editingEntry);
- Deleted?.Invoke(entryId);
- }
+ FormattedCardNumber = CardTypeDetector.FormatCardNumber(CardNumber, DetectedCardType);
}
}
diff --git a/src/PassKey.Desktop/ViewModels/CreditCardsListViewModel.cs b/src/PassKey.Desktop/ViewModels/CreditCardsListViewModel.cs
index 1049948..5072c8d 100644
--- a/src/PassKey.Desktop/ViewModels/CreditCardsListViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/CreditCardsListViewModel.cs
@@ -12,12 +12,13 @@ namespace PassKey.Desktop.ViewModels;
///
/// Credit cards list ViewModel: collection, sort, search, view toggle (card/list), detail panel.
///
-public partial class CreditCardsListViewModel : ObservableObject
+public partial class CreditCardsListViewModel : ObservableObject, IDisposable
{
private readonly IVaultStateService _vaultState;
private readonly IClipboardService _clipboard;
private readonly IDialogQueueService _dialogQueue;
private readonly IVaultRepository _repository;
+ private bool _disposed;
private List _allEntries = [];
@@ -64,6 +65,27 @@ public CreditCardsListViewModel(
_dialogQueue = dialogQueue;
_repository = repository;
_detailVm = detailViewModel;
+
+ _vaultState.VaultLocked += OnVaultLocked;
+ }
+
+ private void OnVaultLocked()
+ {
+ _allEntries = [];
+ Entries.Clear();
+ IsDetailOpen = false;
+ DetailViewModel = null;
+ SelectedEntry = null;
+ SearchQuery = string.Empty;
+ IsEmpty = false;
+ }
+
+ /// Detaches the handler to prevent leaks.
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _vaultState.VaultLocked -= OnVaultLocked;
}
[RelayCommand]
@@ -177,20 +199,13 @@ private async Task DeleteSelectedAsync()
{
if (SelectedEntry is null) return;
- var result = await _dialogQueue.EnqueueAndWait(() =>
- {
- var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
- {
- Title = "Elimina carta",
- Content = $"Eliminare \"{SelectedEntry.Label}\"?\nQuesta azione è irreversibile.",
- PrimaryButtonText = "Elimina",
- CloseButtonText = "Annulla",
- DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close
- };
- return dialog.ShowAsync().AsTask();
- });
+ var confirmed = await _dialogQueue.ConfirmAsync(
+ title: "Elimina carta",
+ content: $"Eliminare \"{SelectedEntry.Label}\"?\nQuesta azione è irreversibile.",
+ primaryButtonText: "Elimina",
+ closeButtonText: "Annulla");
- if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
+ if (confirmed)
{
var vault = _vaultState.CurrentVault;
var entryId = SelectedEntry.Id;
diff --git a/src/PassKey.Desktop/ViewModels/IdentitiesListViewModel.cs b/src/PassKey.Desktop/ViewModels/IdentitiesListViewModel.cs
index 1a3c1bd..bd23783 100644
--- a/src/PassKey.Desktop/ViewModels/IdentitiesListViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/IdentitiesListViewModel.cs
@@ -10,12 +10,13 @@ namespace PassKey.Desktop.ViewModels;
///
/// Identities list ViewModel: collection, sort, search, CRUD, detail panel.
///
-public partial class IdentitiesListViewModel : ObservableObject
+public partial class IdentitiesListViewModel : ObservableObject, IDisposable
{
private readonly IVaultStateService _vaultState;
private readonly IClipboardService _clipboard;
private readonly IDialogQueueService _dialogQueue;
private readonly IVaultRepository _repository;
+ private bool _disposed;
private List _allEntries = [];
@@ -56,6 +57,27 @@ public IdentitiesListViewModel(
_dialogQueue = dialogQueue;
_repository = repository;
_detailVm = detailViewModel;
+
+ _vaultState.VaultLocked += OnVaultLocked;
+ }
+
+ private void OnVaultLocked()
+ {
+ _allEntries = [];
+ Entries.Clear();
+ IsDetailOpen = false;
+ DetailViewModel = null;
+ SelectedEntry = null;
+ SearchQuery = string.Empty;
+ IsEmpty = false;
+ }
+
+ /// Detaches the handler to prevent leaks.
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _vaultState.VaultLocked -= OnVaultLocked;
}
[RelayCommand]
@@ -174,20 +196,13 @@ private async Task DeleteSelectedAsync()
{
if (SelectedEntry is null) return;
- var result = await _dialogQueue.EnqueueAndWait(() =>
- {
- var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
- {
- Title = "Elimina identità",
- Content = $"Eliminare \"{SelectedEntry.Label}\"?\nQuesta azione è irreversibile.",
- PrimaryButtonText = "Elimina",
- CloseButtonText = "Annulla",
- DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close
- };
- return dialog.ShowAsync().AsTask();
- });
+ var confirmed = await _dialogQueue.ConfirmAsync(
+ title: "Elimina identità",
+ content: $"Eliminare \"{SelectedEntry.Label}\"?\nQuesta azione è irreversibile.",
+ primaryButtonText: "Elimina",
+ closeButtonText: "Annulla");
- if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
+ if (confirmed)
{
var vault = _vaultState.CurrentVault;
var entryId = SelectedEntry.Id;
diff --git a/src/PassKey.Desktop/ViewModels/IdentityDetailViewModel.cs b/src/PassKey.Desktop/ViewModels/IdentityDetailViewModel.cs
index 496c628..17e9adb 100644
--- a/src/PassKey.Desktop/ViewModels/IdentityDetailViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/IdentityDetailViewModel.cs
@@ -1,28 +1,17 @@
using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
using PassKey.Core.Models;
using PassKey.Desktop.Services;
+using PassKey.Desktop.ViewModels.Base;
namespace PassKey.Desktop.ViewModels;
///
/// Identity detail ViewModel for add/edit panel.
/// Form organized in 3 expandable sections: Personal Data, Address, Documents + Notes.
+/// Shared add/edit/save/delete plumbing is provided by .
///
-public partial class IdentityDetailViewModel : ObservableObject
+public partial class IdentityDetailViewModel : BaseDetailViewModel
{
- private readonly IVaultStateService _vaultState;
- private readonly IDialogQueueService _dialogQueue;
-
- private IdentityEntry? _editingEntry;
- private bool _isNew;
-
- public bool IsNew => _isNew;
-
- // Panel
- [ObservableProperty]
- public partial string PanelTitle { get; set; } = string.Empty;
-
// Personal Data
[ObservableProperty]
public partial string Label { get; set; } = string.Empty;
@@ -78,45 +67,52 @@ public partial class IdentityDetailViewModel : ObservableObject
[ObservableProperty]
public partial string Notes { get; set; } = string.Empty;
- // State
- [ObservableProperty]
- public partial bool CanSave { get; set; }
-
- [ObservableProperty]
- public partial bool IsSaving { get; set; }
-
- /// Callback when save completes: (isNew, entryId).
- public Action? Saved { get; set; }
-
- /// Callback when delete completes: (entryId).
- public Action? Deleted { get; set; }
-
- /// Callback when cancel is clicked.
- public Action? Cancelled { get; set; }
-
public IdentityDetailViewModel(
IVaultStateService vaultState,
IDialogQueueService dialogQueue)
+ : base(vaultState, dialogQueue)
{
- _vaultState = vaultState;
- _dialogQueue = dialogQueue;
}
- public void StartNew()
+ // ─── Template-method overrides ────────────────────────────────────────────
+
+ protected override string GetPanelTitleForNew() => "Aggiungi identità";
+ protected override string GetPanelTitleForEdit() => "Modifica identità";
+ protected override string GetDeleteDialogTitle() => "Elimina identità";
+
+ protected override string GetDeleteDisplayName(IdentityEntry entry)
{
- _editingEntry = null;
- _isNew = true;
- PanelTitle = "Aggiungi identità";
- ClearAllFields();
- UpdateCanSave();
+ var displayName = !string.IsNullOrWhiteSpace(entry.Label)
+ ? entry.Label
+ : $"{entry.FirstName} {entry.LastName}".Trim();
+ return string.IsNullOrWhiteSpace(displayName) ? "Identità senza nome" : displayName;
}
- public void StartEdit(IdentityEntry entry)
+ protected override IList GetVaultCollection(Vault vault) => vault.Identities;
+
+ protected override void ResetFieldsForNew()
{
- _editingEntry = entry;
- _isNew = false;
- PanelTitle = "Modifica identità";
+ Label = string.Empty;
+ FirstName = string.Empty;
+ LastName = string.Empty;
+ BirthDate = string.Empty;
+ Email = string.Empty;
+ Phone = string.Empty;
+ Street = string.Empty;
+ City = string.Empty;
+ Province = string.Empty;
+ PostalCode = string.Empty;
+ Region = string.Empty;
+ Country = string.Empty;
+ IdCardNumber = string.Empty;
+ HealthCardNumber = string.Empty;
+ DrivingLicenseNumber = string.Empty;
+ PassportNumber = string.Empty;
+ Notes = string.Empty;
+ }
+ protected override void LoadFromEntry(IdentityEntry entry)
+ {
Label = entry.Label;
FirstName = entry.FirstName;
LastName = entry.LastName;
@@ -137,147 +133,59 @@ public void StartEdit(IdentityEntry entry)
PassportNumber = entry.PassportNumber;
Notes = entry.Notes;
- UpdateCanSave();
}
- private void ClearAllFields()
+ protected override IdentityEntry CreateNewEntry() => new()
{
- Label = string.Empty;
- FirstName = string.Empty;
- LastName = string.Empty;
- BirthDate = string.Empty;
- Email = string.Empty;
- Phone = string.Empty;
- Street = string.Empty;
- City = string.Empty;
- Province = string.Empty;
- PostalCode = string.Empty;
- Region = string.Empty;
- Country = string.Empty;
- IdCardNumber = string.Empty;
- HealthCardNumber = string.Empty;
- DrivingLicenseNumber = string.Empty;
- PassportNumber = string.Empty;
- Notes = string.Empty;
+ Label = Label.Trim(),
+ FirstName = FirstName.Trim(),
+ LastName = LastName.Trim(),
+ BirthDate = BirthDate.Trim(),
+ Email = Email.Trim(),
+ Phone = Phone.Trim(),
+ Street = Street.Trim(),
+ City = City.Trim(),
+ Province = Province.Trim(),
+ PostalCode = PostalCode.Trim(),
+ Region = Region.Trim(),
+ Country = Country.Trim(),
+ IdCardNumber = IdCardNumber.Trim(),
+ HealthCardNumber = HealthCardNumber.Trim(),
+ DrivingLicenseNumber = DrivingLicenseNumber.Trim(),
+ PassportNumber = PassportNumber.Trim(),
+ Notes = Notes.Trim()
+ };
+
+ protected override void ApplyToEntry(IdentityEntry entry)
+ {
+ entry.Label = Label.Trim();
+ entry.FirstName = FirstName.Trim();
+ entry.LastName = LastName.Trim();
+ entry.BirthDate = BirthDate.Trim();
+ entry.Email = Email.Trim();
+ entry.Phone = Phone.Trim();
+ entry.Street = Street.Trim();
+ entry.City = City.Trim();
+ entry.Province = Province.Trim();
+ entry.PostalCode = PostalCode.Trim();
+ entry.Region = Region.Trim();
+ entry.Country = Country.Trim();
+ entry.IdCardNumber = IdCardNumber.Trim();
+ entry.HealthCardNumber = HealthCardNumber.Trim();
+ entry.DrivingLicenseNumber = DrivingLicenseNumber.Trim();
+ entry.PassportNumber = PassportNumber.Trim();
+ entry.Notes = Notes.Trim();
}
- // Validation triggers
- partial void OnFirstNameChanged(string value) => UpdateCanSave();
- partial void OnLastNameChanged(string value) => UpdateCanSave();
- partial void OnEmailChanged(string value) => UpdateCanSave();
-
- private void UpdateCanSave()
+ protected override void UpdateCanSave()
{
- // At minimum: first name OR last name required
+ // At minimum: first name OR last name required.
CanSave = !string.IsNullOrWhiteSpace(FirstName) || !string.IsNullOrWhiteSpace(LastName);
}
- [RelayCommand]
- private Task SaveAsync()
- {
- if (!CanSave) return Task.CompletedTask;
-
- var vault = _vaultState.CurrentVault;
- if (vault is null) return Task.CompletedTask;
-
- IsSaving = true;
-
- try
- {
- bool wasNew = _isNew;
- Guid entryId;
-
- if (_isNew)
- {
- var entry = new IdentityEntry
- {
- Label = Label.Trim(),
- FirstName = FirstName.Trim(),
- LastName = LastName.Trim(),
- BirthDate = BirthDate.Trim(),
- Email = Email.Trim(),
- Phone = Phone.Trim(),
- Street = Street.Trim(),
- City = City.Trim(),
- Province = Province.Trim(),
- PostalCode = PostalCode.Trim(),
- Region = Region.Trim(),
- Country = Country.Trim(),
- IdCardNumber = IdCardNumber.Trim(),
- HealthCardNumber = HealthCardNumber.Trim(),
- DrivingLicenseNumber = DrivingLicenseNumber.Trim(),
- PassportNumber = PassportNumber.Trim(),
- Notes = Notes.Trim()
- };
- vault.Identities.Add(entry);
- entryId = entry.Id;
- }
- else if (_editingEntry is not null)
- {
- _editingEntry.Label = Label.Trim();
- _editingEntry.FirstName = FirstName.Trim();
- _editingEntry.LastName = LastName.Trim();
- _editingEntry.BirthDate = BirthDate.Trim();
- _editingEntry.Email = Email.Trim();
- _editingEntry.Phone = Phone.Trim();
- _editingEntry.Street = Street.Trim();
- _editingEntry.City = City.Trim();
- _editingEntry.Province = Province.Trim();
- _editingEntry.PostalCode = PostalCode.Trim();
- _editingEntry.Region = Region.Trim();
- _editingEntry.Country = Country.Trim();
- _editingEntry.IdCardNumber = IdCardNumber.Trim();
- _editingEntry.HealthCardNumber = HealthCardNumber.Trim();
- _editingEntry.DrivingLicenseNumber = DrivingLicenseNumber.Trim();
- _editingEntry.PassportNumber = PassportNumber.Trim();
- _editingEntry.Notes = Notes.Trim();
- _editingEntry.ModifiedAt = DateTime.UtcNow;
- entryId = _editingEntry.Id;
- }
- else
- {
- return Task.CompletedTask;
- }
-
- Saved?.Invoke(wasNew, entryId);
- }
- finally
- {
- IsSaving = false;
- }
-
- return Task.CompletedTask;
- }
+ // ─── Property change handlers ─────────────────────────────────────────────
- [RelayCommand]
- private async Task DeleteAsync()
- {
- if (_editingEntry is null || _isNew) return;
-
- var displayName = !string.IsNullOrWhiteSpace(_editingEntry.Label)
- ? _editingEntry.Label
- : $"{_editingEntry.FirstName} {_editingEntry.LastName}".Trim();
- if (string.IsNullOrWhiteSpace(displayName)) displayName = "Identità senza nome";
-
- var result = await _dialogQueue.EnqueueAndWait(() =>
- {
- var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
- {
- Title = "Elimina identità",
- Content = $"Eliminare \"{displayName}\"?\nQuesta azione è irreversibile.",
- PrimaryButtonText = "Elimina",
- CloseButtonText = "Annulla",
- DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close
- };
- return dialog.ShowAsync().AsTask();
- });
-
- if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
- {
- var vault = _vaultState.CurrentVault;
- var entryId = _editingEntry.Id;
- vault?.Identities.Remove(_editingEntry);
- Deleted?.Invoke(entryId);
- }
- }
+ partial void OnFirstNameChanged(string value) => UpdateCanSave();
+ partial void OnLastNameChanged(string value) => UpdateCanSave();
+ partial void OnEmailChanged(string value) => UpdateCanSave();
}
diff --git a/src/PassKey.Desktop/ViewModels/LoginViewModel.cs b/src/PassKey.Desktop/ViewModels/LoginViewModel.cs
index 495a150..af6fe3f 100644
--- a/src/PassKey.Desktop/ViewModels/LoginViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/LoginViewModel.cs
@@ -67,8 +67,19 @@ private async Task LoginAsync(string password)
try
{
var chars = password.ToCharArray();
- var success = await Task.Run(async () => await _vaultState.UnlockAsync(chars));
- Array.Clear(chars);
+ // Run KDF + decrypt on the thread pool so the synchronous CPU-bound parts
+ // of UnlockAsync (Argon2id / AES-GCM) do not freeze the UI thread.
+ // Using Task.Run(Func>) — NOT Task.Run(async lambda) — to avoid the
+ // anti-pattern of an async lambda just awaiting another async call.
+ bool success;
+ try
+ {
+ success = await Task.Run(() => _vaultState.UnlockAsync(chars));
+ }
+ finally
+ {
+ Array.Clear(chars);
+ }
if (success)
{
diff --git a/src/PassKey.Desktop/ViewModels/MainViewModel.cs b/src/PassKey.Desktop/ViewModels/MainViewModel.cs
index fe55858..d7610fa 100644
--- a/src/PassKey.Desktop/ViewModels/MainViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/MainViewModel.cs
@@ -14,13 +14,16 @@ namespace PassKey.Desktop.ViewModels;
/// , , .
/// Subscribes to to redirect to the login screen
/// whenever the vault is locked programmatically or after auto-lock timeout.
+/// Implements to detach event handlers and prevent leaks if the
+/// instance is ever re-created within the same process lifetime.
///
-public partial class MainViewModel : ObservableObject
+public partial class MainViewModel : ObservableObject, IDisposable
{
private readonly IVaultRepository _repository;
private readonly INavigationStack _navigation;
private readonly IDatabaseService _database;
private readonly IVaultStateService _vaultState;
+ private bool _disposed;
///
/// Gets or sets the currently active page ViewModel displayed in the main content area.
@@ -47,10 +50,13 @@ public MainViewModel(
_database = database;
_vaultState = vaultState;
- _navigation.CurrentChanged += vm => CurrentPage = vm;
+ // Named handlers (not lambdas) so they can be unsubscribed in Dispose.
+ _navigation.CurrentChanged += OnNavigationCurrentChanged;
_vaultState.VaultLocked += OnVaultLocked;
}
+ private void OnNavigationCurrentChanged(ObservableObject? vm) => CurrentPage = vm;
+
private void OnVaultLocked()
{
_navigation.Clear();
@@ -76,4 +82,17 @@ public async Task InitializeAsync()
_navigation.NavigateTo();
}
}
+
+ ///
+ /// Detaches event handlers from and
+ /// to prevent memory leaks if the instance is replaced.
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ _navigation.CurrentChanged -= OnNavigationCurrentChanged;
+ _vaultState.VaultLocked -= OnVaultLocked;
+ }
}
diff --git a/src/PassKey.Desktop/ViewModels/PasswordDetailViewModel.cs b/src/PassKey.Desktop/ViewModels/PasswordDetailViewModel.cs
index 1132af7..7ac56dc 100644
--- a/src/PassKey.Desktop/ViewModels/PasswordDetailViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/PasswordDetailViewModel.cs
@@ -3,26 +3,20 @@
using PassKey.Core.Models;
using PassKey.Core.Services;
using PassKey.Desktop.Services;
+using PassKey.Desktop.ViewModels.Base;
namespace PassKey.Desktop.ViewModels;
///
/// Password detail ViewModel for add/edit panel.
/// Fields: Title, URL, Username, Password, Notes.
+/// Shared add/edit/save/delete plumbing is provided by .
///
-public partial class PasswordDetailViewModel : ObservableObject
+public partial class PasswordDetailViewModel : BaseDetailViewModel
{
- private readonly IVaultStateService _vaultState;
private readonly IPasswordGenerator _generator;
- private readonly IDialogQueueService _dialogQueue;
private readonly IPasswordStrengthAnalyzer _strengthAnalyzer;
- private PasswordEntry? _editingEntry;
- private bool _isNew;
-
- [ObservableProperty]
- public partial string PanelTitle { get; set; } = string.Empty;
-
[ObservableProperty]
public partial string Title { get; set; } = string.Empty;
@@ -41,85 +35,89 @@ public partial class PasswordDetailViewModel : ObservableObject
[ObservableProperty]
public partial string? FaviconBase64 { get; set; }
- [ObservableProperty]
- public partial bool CanSave { get; set; }
-
- [ObservableProperty]
- public partial bool IsSaving { get; set; }
-
[ObservableProperty]
public partial int PasswordStrengthScore { get; set; }
[ObservableProperty]
public partial string PasswordStrengthLabel { get; set; } = string.Empty;
- /// Whether this is a new entry (for subtitle logic).
- public bool IsNew => _isNew;
-
- /// Callback when save completes: (isNew, entryId).
- public Action? Saved { get; set; }
-
- /// Callback when delete completes: (entryId).
- public Action? Deleted { get; set; }
-
- /// Callback when cancel is clicked.
- public Action? Cancelled { get; set; }
-
public PasswordDetailViewModel(
IVaultStateService vaultState,
IPasswordGenerator generator,
IDialogQueueService dialogQueue,
IPasswordStrengthAnalyzer strengthAnalyzer)
+ : base(vaultState, dialogQueue)
{
- _vaultState = vaultState;
_generator = generator;
- _dialogQueue = dialogQueue;
_strengthAnalyzer = strengthAnalyzer;
}
- public void StartNew()
+ // ─── Template-method overrides ────────────────────────────────────────────
+
+ protected override string GetPanelTitleForNew() => "Aggiungi password";
+ protected override string GetPanelTitleForEdit() => "Modifica password";
+ protected override string GetDeleteDialogTitle() => "Elimina password";
+ protected override string GetDeleteDisplayName(PasswordEntry entry) => entry.Title;
+
+ protected override IList GetVaultCollection(Vault vault) => vault.Passwords;
+
+ protected override void ResetFieldsForNew()
{
- _editingEntry = null;
- _isNew = true;
- PanelTitle = "Aggiungi password";
Title = string.Empty;
Url = string.Empty;
Username = string.Empty;
Password = string.Empty;
Notes = string.Empty;
FaviconBase64 = null;
- UpdateCanSave();
}
- public void StartEdit(PasswordEntry entry)
+ protected override void LoadFromEntry(PasswordEntry entry)
{
- _editingEntry = entry;
- _isNew = false;
- PanelTitle = "Modifica password";
Title = entry.Title;
Url = entry.Url;
Username = entry.Username;
Password = entry.Password;
Notes = entry.Notes;
FaviconBase64 = entry.FaviconBase64;
- UpdateCanSave();
}
- partial void OnTitleChanged(string value) => UpdateCanSave();
- partial void OnUsernameChanged(string value) => UpdateCanSave();
- partial void OnPasswordChanged(string value)
+ protected override PasswordEntry CreateNewEntry() => new()
{
- UpdateCanSave();
- UpdatePasswordStrength();
+ Title = Title.Trim(),
+ Url = Url.Trim(),
+ Username = Username.Trim(),
+ Password = Password,
+ Notes = Notes.Trim(),
+ FaviconBase64 = FaviconBase64
+ };
+
+ protected override void ApplyToEntry(PasswordEntry entry)
+ {
+ entry.Title = Title.Trim();
+ entry.Url = Url.Trim();
+ entry.Username = Username.Trim();
+ entry.Password = Password;
+ entry.Notes = Notes.Trim();
+ entry.FaviconBase64 = FaviconBase64;
}
- private void UpdateCanSave()
+ protected override void UpdateCanSave()
{
CanSave = !string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Username) &&
!string.IsNullOrWhiteSpace(Password);
}
+ // ─── Property change handlers ─────────────────────────────────────────────
+
+ partial void OnTitleChanged(string value) => UpdateCanSave();
+ partial void OnUsernameChanged(string value) => UpdateCanSave();
+ partial void OnPasswordChanged(string value)
+ {
+ UpdateCanSave();
+ UpdatePasswordStrength();
+ }
+
private void UpdatePasswordStrength()
{
if (string.IsNullOrEmpty(Password))
@@ -133,6 +131,8 @@ private void UpdatePasswordStrength()
PasswordStrengthLabel = result.Label;
}
+ // ─── Type-specific command ────────────────────────────────────────────────
+
[RelayCommand]
private void GeneratePassword()
{
@@ -145,86 +145,4 @@ private void GeneratePassword()
IncludeSymbols = true
});
}
-
- [RelayCommand]
- private Task SaveAsync()
- {
- if (!CanSave) return Task.CompletedTask;
-
- var vault = _vaultState.CurrentVault;
- if (vault is null) return Task.CompletedTask;
-
- IsSaving = true;
-
- try
- {
- bool wasNew = _isNew;
- Guid entryId;
-
- if (_isNew)
- {
- var entry = new PasswordEntry
- {
- Title = Title.Trim(),
- Url = Url.Trim(),
- Username = Username.Trim(),
- Password = Password,
- Notes = Notes.Trim(),
- FaviconBase64 = FaviconBase64
- };
- vault.Passwords.Add(entry);
- entryId = entry.Id;
- }
- else if (_editingEntry is not null)
- {
- _editingEntry.Title = Title.Trim();
- _editingEntry.Url = Url.Trim();
- _editingEntry.Username = Username.Trim();
- _editingEntry.Password = Password;
- _editingEntry.Notes = Notes.Trim();
- _editingEntry.FaviconBase64 = FaviconBase64;
- _editingEntry.ModifiedAt = DateTime.UtcNow;
- entryId = _editingEntry.Id;
- }
- else
- {
- return Task.CompletedTask;
- }
-
- Saved?.Invoke(wasNew, entryId);
- }
- finally
- {
- IsSaving = false;
- }
-
- return Task.CompletedTask;
- }
-
- [RelayCommand]
- private async Task DeleteAsync()
- {
- if (_editingEntry is null || _isNew) return;
-
- var result = await _dialogQueue.EnqueueAndWait(() =>
- {
- var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
- {
- Title = "Elimina password",
- Content = $"Eliminare \"{_editingEntry.Title}\"?\nQuesta azione è irreversibile.",
- PrimaryButtonText = "Elimina",
- CloseButtonText = "Annulla",
- DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close
- };
- return dialog.ShowAsync().AsTask();
- });
-
- if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
- {
- var vault = _vaultState.CurrentVault;
- var entryId = _editingEntry.Id;
- vault?.Passwords.Remove(_editingEntry);
- Deleted?.Invoke(entryId);
- }
- }
}
diff --git a/src/PassKey.Desktop/ViewModels/PasswordsListViewModel.cs b/src/PassKey.Desktop/ViewModels/PasswordsListViewModel.cs
index 7d9a018..31b2009 100644
--- a/src/PassKey.Desktop/ViewModels/PasswordsListViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/PasswordsListViewModel.cs
@@ -10,12 +10,13 @@ namespace PassKey.Desktop.ViewModels;
///
/// Passwords list ViewModel: collection, sort, search, selected entry, detail panel state.
///
-public partial class PasswordsListViewModel : ObservableObject
+public partial class PasswordsListViewModel : ObservableObject, IDisposable
{
private readonly IVaultStateService _vaultState;
private readonly IClipboardService _clipboard;
private readonly IDialogQueueService _dialogQueue;
private readonly IVaultRepository _repository;
+ private bool _disposed;
private List _allEntries = [];
@@ -58,6 +59,28 @@ public PasswordsListViewModel(
_dialogQueue = dialogQueue;
_repository = repository;
_detailVm = detailViewModel;
+
+ // Hygiene: clear in-memory entries when the vault is locked so we never
+ // retain decrypted data after lock. Named handler so it can be unsubscribed.
+ _vaultState.VaultLocked += OnVaultLocked;
+ }
+
+ private void OnVaultLocked()
+ {
+ _allEntries = [];
+ Entries.Clear();
+ CloseDetail();
+ SelectedEntry = null;
+ SearchQuery = string.Empty;
+ IsEmpty = false;
+ }
+
+ /// Detaches the handler to prevent leaks.
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _vaultState.VaultLocked -= OnVaultLocked;
}
[RelayCommand]
@@ -169,20 +192,13 @@ private async Task DeleteSelectedAsync()
{
if (SelectedEntry is null) return;
- var result = await _dialogQueue.EnqueueAndWait(() =>
- {
- var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
- {
- Title = "Elimina password",
- Content = $"Eliminare \"{SelectedEntry.Title}\"?\nQuesta azione è irreversibile.",
- PrimaryButtonText = "Elimina",
- CloseButtonText = "Annulla",
- DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close
- };
- return dialog.ShowAsync().AsTask();
- });
+ var confirmed = await _dialogQueue.ConfirmAsync(
+ title: "Elimina password",
+ content: $"Eliminare \"{SelectedEntry.Title}\"?\nQuesta azione è irreversibile.",
+ primaryButtonText: "Elimina",
+ closeButtonText: "Annulla");
- if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
+ if (confirmed)
{
var vault = _vaultState.CurrentVault;
var entryId = SelectedEntry.Id;
diff --git a/src/PassKey.Desktop/ViewModels/SecureNoteDetailViewModel.cs b/src/PassKey.Desktop/ViewModels/SecureNoteDetailViewModel.cs
index 46868b6..ab5c582 100644
--- a/src/PassKey.Desktop/ViewModels/SecureNoteDetailViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/SecureNoteDetailViewModel.cs
@@ -3,6 +3,7 @@
using PassKey.Core.Constants;
using PassKey.Core.Models;
using PassKey.Desktop.Services;
+using PassKey.Desktop.ViewModels.Base;
namespace PassKey.Desktop.ViewModels;
@@ -10,24 +11,21 @@ namespace PassKey.Desktop.ViewModels;
/// Secure note detail ViewModel for the editor panel.
/// Fields: Title, Category (with color), Content (multiline), character/word counter,
/// pin toggle, unsaved changes indicator.
+/// Shared add/edit/save/delete plumbing is provided by .
///
-public partial class SecureNoteDetailViewModel : ObservableObject
+///
+/// Unlike the other detail ViewModels, after a successful "create new" save this VM
+/// keeps the panel open and transitions in-place to "edit" mode, so the user can keep
+/// writing without re-opening the entry.
+///
+public partial class SecureNoteDetailViewModel : BaseDetailViewModel
{
- private readonly IVaultStateService _vaultState;
- private readonly IDialogQueueService _dialogQueue;
-
- private SecureNoteEntry? _editingEntry;
- private bool _isNew;
-
- // Snapshot valori originali per tracking "non salvato"
+ // Snapshot of original values for "unsaved changes" tracking.
private string _originalTitle = string.Empty;
private string _originalContent = string.Empty;
private NoteCategory _originalCategory = NoteCategory.General;
private bool _originalIsPinned;
- [ObservableProperty]
- public partial string PanelTitle { get; set; } = string.Empty;
-
[ObservableProperty]
public partial string Title { get; set; } = string.Empty;
@@ -43,12 +41,6 @@ public partial class SecureNoteDetailViewModel : ObservableObject
[ObservableProperty]
public partial int WordCount { get; set; }
- [ObservableProperty]
- public partial bool CanSave { get; set; }
-
- [ObservableProperty]
- public partial bool IsSaving { get; set; }
-
[ObservableProperty]
public partial bool IsEditMode { get; set; }
@@ -58,32 +50,28 @@ public partial class SecureNoteDetailViewModel : ObservableObject
[ObservableProperty]
public partial bool HasUnsavedChanges { get; set; }
- /// Callback when save completes: (isNew, entryId).
- public Action? Saved { get; set; }
-
- /// Callback when delete completes: (entryId).
- public Action? Deleted { get; set; }
-
- /// Callback when cancel is clicked.
- public Action? Cancelled { get; set; }
-
- /// Callback when pin is toggled (instant save, no Save button needed).
+ /// Raised when is toggled (instant-save, no Save button needed).
public Action? PinToggled { get; set; }
public SecureNoteDetailViewModel(
IVaultStateService vaultState,
IDialogQueueService dialogQueue)
+ : base(vaultState, dialogQueue)
{
- _vaultState = vaultState;
- _dialogQueue = dialogQueue;
}
- public void StartNew()
+ // ─── Template-method overrides ────────────────────────────────────────────
+
+ protected override string GetPanelTitleForNew() => "Nuova nota";
+ protected override string GetPanelTitleForEdit() => "Modifica nota";
+ protected override string GetDeleteDialogTitle() => "Elimina nota";
+ protected override string GetDeleteDisplayName(SecureNoteEntry entry)
+ => !string.IsNullOrWhiteSpace(entry.Title) ? entry.Title : "Nota senza titolo";
+
+ protected override IList GetVaultCollection(Vault vault) => vault.SecureNotes;
+
+ protected override void ResetFieldsForNew()
{
- _editingEntry = null;
- _isNew = true;
- IsEditMode = false;
- PanelTitle = "Nuova nota";
Title = string.Empty;
Category = NoteCategory.General;
Content = string.Empty;
@@ -91,21 +79,16 @@ public void StartNew()
WordCount = 0;
IsPinned = false;
HasUnsavedChanges = false;
+ IsEditMode = false;
_originalTitle = string.Empty;
_originalContent = string.Empty;
_originalCategory = NoteCategory.General;
_originalIsPinned = false;
-
- UpdateCanSave();
}
- public void StartEdit(SecureNoteEntry entry)
+ protected override void LoadFromEntry(SecureNoteEntry entry)
{
- _editingEntry = entry;
- _isNew = false;
- IsEditMode = true;
- PanelTitle = "Modifica nota";
Title = entry.Title;
Category = entry.Category;
Content = entry.Content;
@@ -113,44 +96,71 @@ public void StartEdit(SecureNoteEntry entry)
WordCount = CountWords(entry.Content);
IsPinned = entry.IsPinned;
HasUnsavedChanges = false;
+ IsEditMode = true;
_originalTitle = entry.Title;
_originalContent = entry.Content;
_originalCategory = entry.Category;
_originalIsPinned = entry.IsPinned;
+ }
- UpdateCanSave();
+ protected override SecureNoteEntry CreateNewEntry() => new()
+ {
+ Title = Title.Trim(),
+ Category = Category,
+ Content = Content,
+ IsPinned = IsPinned
+ };
+
+ protected override void ApplyToEntry(SecureNoteEntry entry)
+ {
+ entry.Title = Title.Trim();
+ entry.Category = Category;
+ entry.Content = Content;
+ entry.IsPinned = IsPinned;
}
- partial void OnTitleChanged(string value)
+ protected override void UpdateCanSave()
{
- UpdateCanSave();
- UpdateHasUnsavedChanges();
+ CanSave = !string.IsNullOrWhiteSpace(Title);
}
- partial void OnContentChanged(string value)
+ /// After saving a brand-new note, transition the panel to edit-mode in-place
+ /// so the user can keep editing without re-opening the entry.
+ protected override void OnSavedNew(SecureNoteEntry entry)
{
- CharacterCount = value.Length;
- WordCount = CountWords(value);
- UpdateCanSave();
- UpdateHasUnsavedChanges();
+ EditingEntry = entry;
+ SetIsNew(false);
+ IsEditMode = true;
+ PanelTitle = GetPanelTitleForEdit();
+ UpdateSnapshotFromCurrent();
}
- partial void OnCategoryChanged(NoteCategory value)
+ /// After applying edits, refresh the "original" snapshot so the dirty-flag clears.
+ protected override void OnSavedEdit(SecureNoteEntry entry)
{
- UpdateHasUnsavedChanges();
+ UpdateSnapshotFromCurrent();
}
- partial void OnIsPinnedChanged(bool value)
+ // ─── Property change handlers ─────────────────────────────────────────────
+
+ partial void OnTitleChanged(string value)
{
+ UpdateCanSave();
UpdateHasUnsavedChanges();
}
- private void UpdateCanSave()
+ partial void OnContentChanged(string value)
{
- CanSave = !string.IsNullOrWhiteSpace(Title);
+ CharacterCount = value.Length;
+ WordCount = CountWords(value);
+ UpdateCanSave();
+ UpdateHasUnsavedChanges();
}
+ partial void OnCategoryChanged(NoteCategory value) => UpdateHasUnsavedChanges();
+ partial void OnIsPinnedChanged(bool value) => UpdateHasUnsavedChanges();
+
private void UpdateHasUnsavedChanges()
{
HasUnsavedChanges = Title != _originalTitle
@@ -159,118 +169,36 @@ private void UpdateHasUnsavedChanges()
|| IsPinned != _originalIsPinned;
}
+ private void UpdateSnapshotFromCurrent()
+ {
+ _originalTitle = Title;
+ _originalContent = Content;
+ _originalCategory = Category;
+ _originalIsPinned = IsPinned;
+ HasUnsavedChanges = false;
+ }
+
+ // ─── Type-specific command (instant pin toggle, bypasses Save flow) ───────
+
[RelayCommand]
private void TogglePin()
{
IsPinned = !IsPinned;
- // Persiste immediatamente sul model (senza passare da Save)
- if (_editingEntry is not null)
+ // Persist immediately on the model (does not go through Save).
+ if (EditingEntry is not null)
{
- _editingEntry.IsPinned = IsPinned;
+ EditingEntry.IsPinned = IsPinned;
}
- // Aggiorna snapshot: il pin non conta come "modifica non salvata"
+ // The pin toggle does not count as an "unsaved change".
_originalIsPinned = IsPinned;
UpdateHasUnsavedChanges();
- // Callback: salva vault e aggiorna lista
+ // Caller saves the vault to disk and refreshes the list.
PinToggled?.Invoke();
}
- [RelayCommand]
- private Task SaveAsync()
- {
- if (!CanSave) return Task.CompletedTask;
-
- var vault = _vaultState.CurrentVault;
- if (vault is null) return Task.CompletedTask;
-
- IsSaving = true;
-
- try
- {
- bool wasNew = _isNew;
- Guid entryId;
-
- if (_isNew)
- {
- var entry = new SecureNoteEntry
- {
- Title = Title.Trim(),
- Category = Category,
- Content = Content,
- IsPinned = IsPinned
- };
- vault.SecureNotes.Add(entry);
- _editingEntry = entry;
- _isNew = false;
- IsEditMode = true;
- PanelTitle = "Modifica nota";
- entryId = entry.Id;
- }
- else if (_editingEntry is not null)
- {
- _editingEntry.Title = Title.Trim();
- _editingEntry.Category = Category;
- _editingEntry.Content = Content;
- _editingEntry.IsPinned = IsPinned;
- _editingEntry.ModifiedAt = DateTime.UtcNow;
- entryId = _editingEntry.Id;
- }
- else
- {
- return Task.CompletedTask;
- }
-
- // Aggiorna snapshot dopo save riuscito
- _originalTitle = Title;
- _originalContent = Content;
- _originalCategory = Category;
- _originalIsPinned = IsPinned;
- HasUnsavedChanges = false;
-
- Saved?.Invoke(wasNew, entryId);
- }
- finally
- {
- IsSaving = false;
- }
-
- return Task.CompletedTask;
- }
-
- [RelayCommand]
- private async Task DeleteAsync()
- {
- if (_editingEntry is null || _isNew) return;
-
- var displayName = !string.IsNullOrWhiteSpace(_editingEntry.Title)
- ? _editingEntry.Title
- : "Nota senza titolo";
-
- var result = await _dialogQueue.EnqueueAndWait(() =>
- {
- var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
- {
- Title = "Elimina nota",
- Content = $"Eliminare \"{displayName}\"?\nQuesta azione è irreversibile.",
- PrimaryButtonText = "Elimina",
- CloseButtonText = "Annulla",
- DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close
- };
- return dialog.ShowAsync().AsTask();
- });
-
- if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
- {
- var vault = _vaultState.CurrentVault;
- var entryId = _editingEntry.Id;
- vault?.SecureNotes.Remove(_editingEntry);
- Deleted?.Invoke(entryId);
- }
- }
-
private static int CountWords(string text)
{
if (string.IsNullOrWhiteSpace(text)) return 0;
diff --git a/src/PassKey.Desktop/ViewModels/SecureNotesListViewModel.cs b/src/PassKey.Desktop/ViewModels/SecureNotesListViewModel.cs
index 415ba09..8de229d 100644
--- a/src/PassKey.Desktop/ViewModels/SecureNotesListViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/SecureNotesListViewModel.cs
@@ -14,11 +14,12 @@ namespace PassKey.Desktop.ViewModels;
/// search, pin sorting, CRUD.
/// Left panel shows mini-cards with colored left border; right panel shows editor.
///
-public partial class SecureNotesListViewModel : ObservableObject
+public partial class SecureNotesListViewModel : ObservableObject, IDisposable
{
private readonly IVaultStateService _vaultState;
private readonly IDialogQueueService _dialogQueue;
private readonly IVaultRepository _repository;
+ private bool _disposed;
private List _allEntries = [];
@@ -60,6 +61,29 @@ public SecureNotesListViewModel(
_dialogQueue = dialogQueue;
_repository = repository;
_detailVm = detailViewModel;
+
+ _vaultState.VaultLocked += OnVaultLocked;
+ }
+
+ private void OnVaultLocked()
+ {
+ _allEntries = [];
+ Entries.Clear();
+ IsEditorOpen = false;
+ DetailViewModel = null;
+ SelectedEntry = null;
+ SearchQuery = string.Empty;
+ FilterCategory = null;
+ IsEmpty = false;
+ IsFilteredEmpty = false;
+ }
+
+ /// Detaches the handler to prevent leaks.
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _vaultState.VaultLocked -= OnVaultLocked;
}
[RelayCommand]
@@ -160,20 +184,13 @@ private async Task DeleteSelectedAsync()
{
if (SelectedEntry is null) return;
- var result = await _dialogQueue.EnqueueAndWait(() =>
- {
- var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
- {
- Title = "Elimina nota",
- Content = $"Eliminare \"{SelectedEntry.Title}\"?\nQuesta azione è irreversibile.",
- PrimaryButtonText = "Elimina",
- CloseButtonText = "Annulla",
- DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close
- };
- return dialog.ShowAsync().AsTask();
- });
+ var confirmed = await _dialogQueue.ConfirmAsync(
+ title: "Elimina nota",
+ content: $"Eliminare \"{SelectedEntry.Title}\"?\nQuesta azione è irreversibile.",
+ primaryButtonText: "Elimina",
+ closeButtonText: "Annulla");
- if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
+ if (confirmed)
{
var vault = _vaultState.CurrentVault;
var entryId = SelectedEntry.Id;
diff --git a/src/PassKey.Desktop/ViewModels/SettingsViewModel.cs b/src/PassKey.Desktop/ViewModels/SettingsViewModel.cs
index 126244e..0739d33 100644
--- a/src/PassKey.Desktop/ViewModels/SettingsViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/SettingsViewModel.cs
@@ -417,6 +417,22 @@ public async Task ImportDataAsync()
// 4. Parse file
var importedVault = await _importOrchestrator.ParseFileAsync(path, format, importPassword);
+ var totalImported = importedVault.Passwords.Count
+ + importedVault.CreditCards.Count
+ + importedVault.Identities.Count
+ + importedVault.SecureNotes.Count;
+
+ // Guard: if the imported file produced no entries (e.g. empty/malformed CSV,
+ // unsupported export structure, headers that we don't recognise), surface
+ // an explicit "no data" error instead of silently advancing to the merge
+ // dialog and then showing "Import completed" with zero changes applied.
+ if (totalImported == 0)
+ {
+ if (OperationError is not null)
+ await OperationError.Invoke("Il file selezionato non contiene alcuna voce riconoscibile. Verifica che l'esportazione di origine non sia vuota e che il formato sia supportato.");
+ return;
+ }
+
// 5. Show counts and ask for merge strategy
if (MergeStrategyRequested is null) return;
var (strategy, mergeConfirmed) = await MergeStrategyRequested.Invoke(
diff --git a/src/PassKey.Desktop/ViewModels/SetupViewModel.cs b/src/PassKey.Desktop/ViewModels/SetupViewModel.cs
index 85ee53f..62c69af 100644
--- a/src/PassKey.Desktop/ViewModels/SetupViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/SetupViewModel.cs
@@ -110,8 +110,17 @@ private async Task CreateVaultAsync(string password)
try
{
var chars = password.ToCharArray();
- await Task.Run(async () => await _vaultState.InitializeAsync(chars));
- Array.Clear(chars);
+ // Run KDF on the thread pool to keep the UI responsive during Argon2id.
+ // Using Task.Run(Func) — not Task.Run(async lambda) — to avoid the
+ // anti-pattern of an async lambda that simply awaits another async call.
+ try
+ {
+ await Task.Run(() => _vaultState.InitializeAsync(chars));
+ }
+ finally
+ {
+ Array.Clear(chars);
+ }
_navigation.Replace();
}
diff --git a/src/PassKey.Tests/CsvImporterTests.cs b/src/PassKey.Tests/CsvImporterTests.cs
index 6fcfd9d..2df1b52 100644
--- a/src/PassKey.Tests/CsvImporterTests.cs
+++ b/src/PassKey.Tests/CsvImporterTests.cs
@@ -103,4 +103,38 @@ public void ParseCsv_SkipsEmptyRows()
Assert.Single(vault.Passwords);
Assert.Equal("GitHub", vault.Passwords[0].Title);
}
+
+ [Fact]
+ public void ParseCsv_NordPassFormat_OnlyPasswordTypeImported()
+ {
+ // NordPass export format: 24 columns including a "type" column that distinguishes
+ // password / credit_card / identity / note rows. Only the password rows should be
+ // imported here; the other types must be skipped (richer import is future work).
+ var csv =
+ "name,url,additional_urls,username,password,note,cardholdername,cardnumber,cvc,pin,expirydate,zipcode,folder,shared_folder,full_name,phone_number,email,address1,address2,city,country,state,type,custom_fields\n" +
+ "GitHub,https://github.com,,me@x.com,pw1,,,,,,,,,,,,,,,,,,password,\n" +
+ "My Visa,,,,,,John Doe,4111111111111111,123,,12/30,,,,,,,,,,,,credit_card,\n" +
+ "Personale,,,,,,,,,,,,,,Mario Rossi,3331234567,mario@x.com,Via X 1,,Milano,IT,MI,identity,\n" +
+ "Wifi key,,,,,Pass: hunter2,,,,,,,,,,,,,,,,,note,\n" +
+ "GitLab,https://gitlab.com,,me@x.com,pw2,Secondary note,,,,,,,,,,,,,,,,,password,";
+
+ var vault = _importer.ParseCsv(csv);
+
+ Assert.Equal(2, vault.Passwords.Count);
+ Assert.Equal("GitHub", vault.Passwords[0].Title);
+ Assert.Equal("pw1", vault.Passwords[0].Password);
+ Assert.Equal("GitLab", vault.Passwords[1].Title);
+ Assert.Equal("Secondary note", vault.Passwords[1].Notes);
+ }
+
+ [Fact]
+ public void ParseCsv_NoteSingularAlias_ImportedAsNotes()
+ {
+ // NordPass uses "note" (singular). The legacy mapper only accepted "notes".
+ var csv = "name,username,password,note\nGitHub,me,pw,My singular note";
+ var vault = _importer.ParseCsv(csv);
+
+ Assert.Single(vault.Passwords);
+ Assert.Equal("My singular note", vault.Passwords[0].Notes);
+ }
}