From 71c38b256989d9cac99de751427c18bb880e1e3d Mon Sep 17 00:00:00 2001 From: Giuseppe Imperato Date: Sat, 16 May 2026 13:37:21 +0200 Subject: [PATCH] =?UTF-8?q?refactor(2.0):=20cluster=201=20=E2=80=94=20code?= =?UTF-8?q?=20quality,=20DI=20hardening,=20dialog=20XamlRoot=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code quality / modernization (Cluster 1 of PassKey 2.0): - Extract BaseDetailViewModel with template-method pattern; the four Detail VMs (Password, CreditCard, Identity, SecureNote) now inherit shared add/edit/save/delete plumbing (~200 LOC of duplication removed) - Introduce IVaultEntry interface as the generic constraint for BaseDetailViewModel - Replace Task.Run(async () => await …) anti-pattern in LoginVM/SetupVM with Task.Run(Func) so the KDF/decrypt work stays off the UI thread without the nested async-over-async wrapper - Implement IDisposable on MainVM and the four List VMs; named handlers replace closures so VaultLocked / NavigationStack.CurrentChanged can be unsubscribed, and Entries collections are cleared on vault lock to avoid retaining decrypted data in memory after lock - Extract Maestro/Visa/Amex/Discover/Diners BIN prefixes in CardTypeDetector to a dedicated BinPrefixes static class with explicit named constants Dialog / UX fixes: - Pre-existing bug: ContentDialog.ShowAsync threw "This element does not have a XamlRoot" on every delete confirmation triggered from a ViewModel (Detail and List). Introduce IDialogQueueService.ConfirmAsync + a lazy XamlRootAccessor delegate that resolves Window.Content.XamlRoot just-in-time on the UI thread, removing the duplicated factory pattern at five call sites and fixing the crash - Pre-existing UX bug: importing an empty / unrecognised file silently advanced to the merge dialog and then showed "Import completed" with zero changes. SettingsViewModel.ImportAsync now surfaces an explicit "no recognisable entries" error when the parsed vault is empty CSV importer (NordPass support): - Add "note" (singular) as alias for the Notes column header - Detect a "type" column and skip non-password rows so card / identity / note labels do not pollute the imported password list (proper cross-type import is tracked for a future cluster) - Two new unit tests cover the NordPass-shape header and the singular-note alias Test suite: 178 passed (was 176). Verified locally: build-installer.ps1 produces both PassKey-Setup-x64.exe and PassKey-Portable-x64.zip; user-facing smoke test (delete on all four entry types, import of a real NordPass CSV with 238 password rows, lock/unlock cycle) confirms no regressions and that the previously broken delete + import flows now work. --- src/PassKey.Core/Models/CreditCardEntry.cs | 2 +- src/PassKey.Core/Models/IVaultEntry.cs | 15 + src/PassKey.Core/Models/IdentityEntry.cs | 2 +- src/PassKey.Core/Models/PasswordEntry.cs | 2 +- src/PassKey.Core/Models/SecureNoteEntry.cs | 2 +- src/PassKey.Core/Services/CardTypeDetector.cs | 58 +++- src/PassKey.Core/Services/CsvImporter.cs | 33 ++- src/PassKey.Desktop/MainWindow.xaml.cs | 7 + .../Services/DialogQueueService.cs | 30 ++ .../Services/IDialogQueueService.cs | 44 +++ .../ViewModels/Base/BaseDetailViewModel.cs | 218 +++++++++++++++ .../ViewModels/CreditCardDetailViewModel.cs | 198 ++++---------- .../ViewModels/CreditCardsListViewModel.cs | 43 ++- .../ViewModels/IdentitiesListViewModel.cs | 43 ++- .../ViewModels/IdentityDetailViewModel.cs | 258 ++++++------------ .../ViewModels/LoginViewModel.cs | 15 +- .../ViewModels/MainViewModel.cs | 23 +- .../ViewModels/PasswordDetailViewModel.cs | 172 +++--------- .../ViewModels/PasswordsListViewModel.cs | 44 ++- .../ViewModels/SecureNoteDetailViewModel.cs | 234 ++++++---------- .../ViewModels/SecureNotesListViewModel.cs | 45 ++- .../ViewModels/SettingsViewModel.cs | 16 ++ .../ViewModels/SetupViewModel.cs | 13 +- src/PassKey.Tests/CsvImporterTests.cs | 34 +++ 24 files changed, 874 insertions(+), 677 deletions(-) create mode 100644 src/PassKey.Core/Models/IVaultEntry.cs create mode 100644 src/PassKey.Desktop/ViewModels/Base/BaseDetailViewModel.cs 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); + } }