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); + } }