refactor(securestoremanager): add platform service abstractions and constants

Implement deferred code review findings:
- Add IDialogService/IClipboardService interfaces for testable platform operations
- Create AvaloniaDialogService and AvaloniaClipboardService implementations
- Extract dialog strings and file extensions to centralized Constants classes
- Refactor ViewModels to use DI instead of event delegates
- Update tests to use mock services
This commit is contained in:
Joseph Doherty
2026-01-19 16:54:35 -05:00
parent 1c546c111a
commit fbe58a81e4
33 changed files with 1790 additions and 298 deletions
@@ -1,12 +1,20 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using JdeScoping.SecureStoreManager.Application;
using JdeScoping.SecureStoreManager.Services;
using JdeScoping.SecureStoreManager.ViewModels;
using JdeScoping.SecureStoreManager.Views;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.SecureStoreManager;
public partial class App : Application
public partial class App : Avalonia.Application
{
public static IServiceProvider Services { get; private set; } = null!;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -14,11 +22,48 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
var services = new ServiceCollection();
ConfigureServices(services);
Services = services.BuildServiceProvider();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
desktop.MainWindow = new MainWindow
{
DataContext = Services.GetRequiredService<MainWindowViewModel>()
};
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureServices(IServiceCollection services)
{
// Logging
services.AddLogging(builder => builder
.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
// Services
services.AddSingleton<ISecureStoreManager, Services.SecureStoreManager>();
// Platform Services (factory pattern for window access)
services.AddSingleton<IDialogService>(sp =>
new AvaloniaDialogService(GetMainWindow));
services.AddSingleton<IClipboardService>(sp =>
new AvaloniaClipboardService(() => GetMainWindow()?.Clipboard));
// Use Cases
services.AddTransient<StoreUseCases>();
services.AddTransient<SecretUseCases>();
// ViewModels
services.AddTransient<MainWindowViewModel>();
}
private Window? GetMainWindow()
{
return (ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
}
}
@@ -0,0 +1,53 @@
using Microsoft.Extensions.Logging;
using JdeScoping.SecureStoreManager.Services;
namespace JdeScoping.SecureStoreManager.Application;
/// <summary>
/// Secret CRUD use-case operations with logging.
/// </summary>
public class SecretUseCases
{
private readonly ISecureStoreManager _storeManager;
private readonly ILogger<SecretUseCases> _logger;
public SecretUseCases(ISecureStoreManager storeManager, ILogger<SecretUseCases> logger)
{
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Sets a secret with the given key and value.
/// </summary>
public void SetSecret(string key, string value)
{
_logger.LogInformation("Setting secret {Key}", key);
_storeManager.SetSecret(key, value);
}
/// <summary>
/// Removes a secret by key.
/// </summary>
public void RemoveSecret(string key)
{
_logger.LogInformation("Removing secret {Key}", key);
_storeManager.RemoveSecret(key);
}
/// <summary>
/// Gets all keys in the current store.
/// </summary>
public IReadOnlyList<string> GetKeys()
{
return _storeManager.GetKeys();
}
/// <summary>
/// Gets the value of a secret by key.
/// </summary>
public string GetSecret(string key)
{
return _storeManager.GetSecret(key);
}
}
@@ -0,0 +1,103 @@
using Microsoft.Extensions.Logging;
using JdeScoping.SecureStoreManager.Services;
namespace JdeScoping.SecureStoreManager.Application;
/// <summary>
/// Store lifecycle use-case operations with logging.
/// </summary>
public class StoreUseCases
{
private readonly ISecureStoreManager _storeManager;
private readonly ILogger<StoreUseCases> _logger;
public StoreUseCases(ISecureStoreManager storeManager, ILogger<StoreUseCases> logger)
{
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Creates a new store with either key file or password authentication.
/// </summary>
public void CreateStore(string storePath, string? keyFilePath, string? password)
{
_logger.LogInformation("Creating store at {StorePath}", storePath);
if (!string.IsNullOrEmpty(keyFilePath))
{
_storeManager.CreateStore(storePath, keyFilePath);
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
}
else if (!string.IsNullOrEmpty(password))
{
_storeManager.CreateStoreWithPassword(storePath, password);
_logger.LogInformation("Password-protected store created");
}
else
{
throw new ArgumentException("Either key file path or password must be provided.");
}
}
/// <summary>
/// Opens an existing store with either key file or password authentication.
/// </summary>
public void OpenStore(string storePath, string? keyFilePath, string? password)
{
_logger.LogInformation("Opening store at {StorePath}", storePath);
if (!string.IsNullOrEmpty(keyFilePath))
{
_storeManager.OpenStore(storePath, keyFilePath);
_logger.LogDebug("Store opened with key file");
}
else if (!string.IsNullOrEmpty(password))
{
_storeManager.OpenStoreWithPassword(storePath, password);
_logger.LogDebug("Store opened with password");
}
else
{
throw new ArgumentException("Either key file path or password must be provided.");
}
}
/// <summary>
/// Closes the currently open store.
/// </summary>
public void CloseStore()
{
_logger.LogInformation("Closing store");
_storeManager.CloseStore();
}
/// <summary>
/// Saves changes to the current store.
/// </summary>
public void Save()
{
_logger.LogInformation("Saving store");
_storeManager.Save();
}
/// <summary>
/// Generates a new key file at the specified path.
/// </summary>
public void GenerateKeyFile(string path)
{
_logger.LogInformation("Generating key file at {Path}", path);
_storeManager.GenerateKeyFile(path);
_logger.LogInformation("Key file generated successfully");
}
/// <summary>
/// Exports the current store's key to a file.
/// </summary>
public void ExportKey(string path)
{
_logger.LogInformation("Exporting key to {Path}", path);
_storeManager.ExportKey(path);
_logger.LogInformation("Key exported successfully");
}
}
@@ -0,0 +1,50 @@
namespace JdeScoping.SecureStoreManager.Constants;
/// <summary>
/// Centralized string constants for dialog titles, messages, and validation errors.
/// </summary>
public static class DialogStrings
{
// Dialog Titles
public const string UnsavedChangesTitle = "Unsaved Changes";
public const string ConfirmDeleteTitle = "Confirm Delete";
public const string ValidationErrorTitle = "Validation Error";
public const string ErrorTitle = "Error";
public const string KeyGeneratedTitle = "Key Generated";
public const string KeyExportedTitle = "Key Exported";
// Messages
public const string UnsavedChangesMessage = "You have unsaved changes. Do you want to save before continuing?";
public const string ConfirmDeleteFormat = "Are you sure you want to delete the secret '{0}'?\n\nThis action cannot be undone.";
public const string DefaultValidationError = "Please fill in all required fields.";
// Validation Messages
public const string StorePathRequired = "Store path is required.";
public const string KeyFilePathRequired = "Key file path is required.";
public const string PasswordRequired = "Password is required.";
public const string PasswordsDoNotMatch = "Passwords do not match.";
public const string KeyRequired = "Key is required.";
public const string StoreFileNotFound = "Store file does not exist.";
public const string KeyFileNotFound = "Key file does not exist.";
// File Dialog Titles
public const string ChooseStoreLocation = "Choose Store Location";
public const string ChooseKeyFileLocation = "Choose Key File Location";
public const string SelectStoreFile = "Select Store File";
public const string SelectKeyFile = "Select Key File";
public const string GenerateKeyFileTitle = "Generate Key File";
public const string ExportKeyTitle = "Export Key";
// Success Message Formats
public const string KeyFileGeneratedFormat = "Key file generated successfully:\n\n{0}";
public const string KeyExportedFormat = "Key exported successfully:\n\n{0}";
// Error Message Formats
public const string FailedToCreateStoreFormat = "Failed to create store:\n\n{0}";
public const string FailedToOpenStoreFormat = "Failed to open store:\n\n{0}";
public const string FailedToSaveStoreFormat = "Failed to save store:\n\n{0}";
public const string FailedToSaveSecretFormat = "Failed to save secret:\n\n{0}";
public const string FailedToDeleteSecretFormat = "Failed to delete secret:\n\n{0}";
public const string FailedToGenerateKeyFormat = "Failed to generate key file:\n\n{0}";
public const string FailedToExportKeyFormat = "Failed to export key:\n\n{0}";
}
@@ -0,0 +1,21 @@
namespace JdeScoping.SecureStoreManager.Constants;
/// <summary>
/// Centralized constants for file extensions and patterns used in file dialogs.
/// </summary>
public static class FileExtensions
{
// SecureStore files
public const string StorePattern = "*.json";
public const string StoreExtension = ".json";
public const string StoreTypeName = "SecureStore Files";
// Key files
public const string KeyPattern = "*.key";
public const string KeyExtension = ".key";
public const string KeyTypeName = "Key Files";
// All files
public const string AllFilesPattern = "*.*";
public const string AllFilesTypeName = "All Files";
}
@@ -13,5 +13,8 @@
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.*" />
<PackageReference Include="MessageBox.Avalonia" Version="3.1.*" />
<PackageReference Include="SecureStore" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.*" />
</ItemGroup>
</Project>
@@ -1,17 +0,0 @@
namespace JdeScoping.SecureStoreManager.Models;
/// <summary>
/// Represents a secret entry with a key and value.
/// </summary>
public class SecretEntry
{
/// <summary>
/// Gets or sets the secret key.
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the secret value.
/// </summary>
public string Value { get; set; } = string.Empty;
}
@@ -0,0 +1,29 @@
using Avalonia.Input.Platform;
namespace JdeScoping.SecureStoreManager.Services;
/// <summary>
/// Avalonia implementation of IClipboardService.
/// </summary>
public class AvaloniaClipboardService : IClipboardService
{
private readonly Func<IClipboard?> _getClipboard;
/// <summary>
/// Creates a new instance of AvaloniaClipboardService.
/// </summary>
/// <param name="getClipboard">Factory function to get the clipboard instance.</param>
public AvaloniaClipboardService(Func<IClipboard?> getClipboard)
{
_getClipboard = getClipboard ?? throw new ArgumentNullException(nameof(getClipboard));
}
public async Task SetTextAsync(string text)
{
var clipboard = _getClipboard();
if (clipboard != null)
{
await clipboard.SetTextAsync(text);
}
}
}
@@ -0,0 +1,135 @@
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using JdeScoping.SecureStoreManager.Constants;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
namespace JdeScoping.SecureStoreManager.Services;
/// <summary>
/// Avalonia implementation of IDialogService using MsBox.Avalonia and platform storage.
/// </summary>
public class AvaloniaDialogService : IDialogService
{
private readonly Func<Window?> _getOwnerWindow;
/// <summary>
/// Creates a new instance of AvaloniaDialogService.
/// </summary>
/// <param name="getOwnerWindow">Factory function to get the owner window for dialogs.</param>
public AvaloniaDialogService(Func<Window?> getOwnerWindow)
{
_getOwnerWindow = getOwnerWindow ?? throw new ArgumentNullException(nameof(getOwnerWindow));
}
public async Task ShowErrorAsync(string message, string title)
{
var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.Ok, Icon.Error);
var window = _getOwnerWindow();
if (window != null)
{
await box.ShowWindowDialogAsync(window);
}
else
{
await box.ShowAsync();
}
}
public async Task ShowInfoAsync(string message, string title)
{
var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.Ok, Icon.Info);
var window = _getOwnerWindow();
if (window != null)
{
await box.ShowWindowDialogAsync(window);
}
else
{
await box.ShowAsync();
}
}
public async Task<bool> ShowConfirmationAsync(string message, string title)
{
var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.YesNo, Icon.Warning);
var window = _getOwnerWindow();
ButtonResult result;
if (window != null)
{
result = await box.ShowWindowDialogAsync(window);
}
else
{
result = await box.ShowAsync();
}
return result == ButtonResult.Yes;
}
public async Task<UnsavedChangesResult> ShowUnsavedChangesPromptAsync()
{
var box = MessageBoxManager.GetMessageBoxStandard(
DialogStrings.UnsavedChangesTitle,
DialogStrings.UnsavedChangesMessage,
ButtonEnum.YesNoCancel,
Icon.Warning);
var window = _getOwnerWindow();
ButtonResult result;
if (window != null)
{
result = await box.ShowWindowDialogAsync(window);
}
else
{
result = await box.ShowAsync();
}
return result switch
{
ButtonResult.Yes => UnsavedChangesResult.Save,
ButtonResult.No => UnsavedChangesResult.DontSave,
_ => UnsavedChangesResult.Cancel
};
}
public async Task<string?> ShowSaveFileDialogAsync(string title, string fileTypeName, string pattern, string defaultExtension)
{
var window = _getOwnerWindow();
if (window == null)
return null;
var file = await window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = title,
DefaultExtension = defaultExtension,
FileTypeChoices = new[]
{
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
new FilePickerFileType(FileExtensions.AllFilesTypeName) { Patterns = new[] { FileExtensions.AllFilesPattern } }
}
});
return file?.Path.LocalPath;
}
public async Task<string?> ShowOpenFileDialogAsync(string title, string fileTypeName, string pattern)
{
var window = _getOwnerWindow();
if (window == null)
return null;
var files = await window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = title,
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
new FilePickerFileType(FileExtensions.AllFilesTypeName) { Patterns = new[] { FileExtensions.AllFilesPattern } }
}
});
return files.Count > 0 ? files[0].Path.LocalPath : null;
}
}
@@ -0,0 +1,14 @@
namespace JdeScoping.SecureStoreManager.Services;
/// <summary>
/// Abstraction for platform-specific clipboard operations.
/// Enables unit testing of view models that need clipboard access.
/// </summary>
public interface IClipboardService
{
/// <summary>
/// Copies text to the system clipboard.
/// </summary>
/// <param name="text">The text to copy.</param>
Task SetTextAsync(string text);
}
@@ -0,0 +1,58 @@
namespace JdeScoping.SecureStoreManager.Services;
/// <summary>
/// Result from unsaved changes prompt.
/// </summary>
public enum UnsavedChangesResult
{
Save,
DontSave,
Cancel
}
/// <summary>
/// Abstraction for platform-specific dialog operations.
/// Enables unit testing of view models that need to show dialogs.
/// </summary>
public interface IDialogService
{
/// <summary>
/// Shows an error message dialog.
/// </summary>
Task ShowErrorAsync(string message, string title);
/// <summary>
/// Shows an informational message dialog.
/// </summary>
Task ShowInfoAsync(string message, string title);
/// <summary>
/// Shows a confirmation dialog with Yes/No options.
/// </summary>
/// <returns>True if user clicked Yes, false otherwise.</returns>
Task<bool> ShowConfirmationAsync(string message, string title);
/// <summary>
/// Shows a prompt for unsaved changes with Save/Don't Save/Cancel options.
/// </summary>
Task<UnsavedChangesResult> ShowUnsavedChangesPromptAsync();
/// <summary>
/// Shows a save file dialog.
/// </summary>
/// <param name="title">Dialog title.</param>
/// <param name="fileTypeName">Display name for the file type (e.g., "Key Files").</param>
/// <param name="pattern">File pattern (e.g., "*.key").</param>
/// <param name="defaultExtension">Default extension (e.g., ".key").</param>
/// <returns>Selected file path or null if cancelled.</returns>
Task<string?> ShowSaveFileDialogAsync(string title, string fileTypeName, string pattern, string defaultExtension);
/// <summary>
/// Shows an open file dialog.
/// </summary>
/// <param name="title">Dialog title.</param>
/// <param name="fileTypeName">Display name for the file type (e.g., "Key Files").</param>
/// <param name="pattern">File pattern (e.g., "*.key").</param>
/// <returns>Selected file path or null if cancelled.</returns>
Task<string?> ShowOpenFileDialogAsync(string title, string fileTypeName, string pattern);
}
@@ -1,14 +1,17 @@
using System.IO;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NeoSmart.SecureStore;
namespace JdeScoping.SecureStoreManager.Services;
/// <summary>
/// Manages SecureStore encrypted secret stores for the WPF application.
/// Manages SecureStore encrypted secret stores for the Avalonia application.
/// </summary>
public class SecureStoreManager : ISecureStoreManager, IDisposable
{
private readonly ILogger<SecureStoreManager> _logger;
private SecretsManager? _secretsManager;
private string? _currentStorePath;
private readonly HashSet<string> _keys = new();
@@ -17,6 +20,26 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
private const string KeysMetadataKey = "__keys__";
private static readonly HashSet<string> ReservedKeys = new(StringComparer.OrdinalIgnoreCase)
{
KeysMetadataKey
};
/// <summary>
/// Creates a new SecureStoreManager with no logging.
/// </summary>
public SecureStoreManager() : this(NullLogger<SecureStoreManager>.Instance)
{
}
/// <summary>
/// Creates a new SecureStoreManager with the specified logger.
/// </summary>
public SecureStoreManager(ILogger<SecureStoreManager> logger)
{
_logger = logger ?? NullLogger<SecureStoreManager>.Instance;
}
/// <inheritdoc />
public bool IsStoreOpen => _secretsManager != null;
@@ -30,6 +53,7 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
public void CreateStore(string storePath, string keyFilePath)
{
ThrowIfDisposed();
_logger.LogInformation("Creating new store at {StorePath}", storePath);
CloseStoreInternal();
EnsureDirectory(storePath);
@@ -44,12 +68,14 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_hasUnsavedChanges = true;
Save();
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
}
/// <inheritdoc />
public void CreateStoreWithPassword(string storePath, string password)
{
ThrowIfDisposed();
_logger.LogInformation("Creating password-protected store at {StorePath}", storePath);
CloseStoreInternal();
if (string.IsNullOrEmpty(password))
@@ -65,12 +91,14 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_hasUnsavedChanges = true;
Save();
_logger.LogInformation("Password-protected store created");
}
/// <inheritdoc />
public void OpenStore(string storePath, string keyFilePath)
{
ThrowIfDisposed();
_logger.LogInformation("Opening store at {StorePath}", storePath);
CloseStoreInternal();
if (!File.Exists(storePath))
@@ -85,12 +113,14 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_currentStorePath = storePath;
LoadKeysMetadata();
_hasUnsavedChanges = false;
_logger.LogDebug("Store opened with key file, contains {KeyCount} keys", _keys.Count);
}
/// <inheritdoc />
public void OpenStoreWithPassword(string storePath, string password)
{
ThrowIfDisposed();
_logger.LogInformation("Opening store at {StorePath} with password", storePath);
CloseStoreInternal();
if (!File.Exists(storePath))
@@ -105,12 +135,14 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_currentStorePath = storePath;
LoadKeysMetadata();
_hasUnsavedChanges = false;
_logger.LogDebug("Store opened with password, contains {KeyCount} keys", _keys.Count);
}
/// <inheritdoc />
public void CloseStore()
{
ThrowIfDisposed();
_logger.LogInformation("Closing store");
CloseStoreInternal();
}
@@ -122,6 +154,7 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
if (_secretsManager == null || _currentStorePath == null)
throw new InvalidOperationException("No store is currently open.");
_logger.LogInformation("Saving store changes");
SaveKeysMetadata();
_secretsManager.SaveStore(_currentStorePath);
_hasUnsavedChanges = false;
@@ -163,9 +196,16 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be empty.", nameof(key));
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("Key cannot be null or whitespace.", nameof(key));
if (ReservedKeys.Contains(key))
{
_logger.LogWarning("Attempted to access reserved key {Key}", key);
throw new ArgumentException($"The key '{key}' is reserved for internal use.", nameof(key));
}
_logger.LogDebug("Setting secret for key {Key}", key);
_secretsManager.Set(key, value ?? string.Empty);
_keys.Add(key);
_hasUnsavedChanges = true;
@@ -179,12 +219,19 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be empty.", nameof(key));
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("Key cannot be null or whitespace.", nameof(key));
if (ReservedKeys.Contains(key))
{
_logger.LogWarning("Attempted to access reserved key {Key}", key);
throw new ArgumentException($"The key '{key}' is reserved for internal use.", nameof(key));
}
if (!_keys.Remove(key))
throw new KeyNotFoundException($"Secret '{key}' not found.");
_logger.LogInformation("Removing secret for key {Key}", key);
_secretsManager.Delete(key);
_hasUnsavedChanges = true;
}
@@ -0,0 +1,72 @@
using System.Windows.Input;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// An async command implementation that properly handles async operations.
/// </summary>
public class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isExecuting;
private EventHandler? _canExecuteChanged;
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
remove => _canExecuteChanged -= value;
}
/// <summary>
/// Creates a new AsyncRelayCommand that can always execute.
/// </summary>
/// <param name="execute">The async action to execute.</param>
public AsyncRelayCommand(Func<Task> execute)
: this(execute, null)
{
}
/// <summary>
/// Creates a new AsyncRelayCommand with a CanExecute predicate.
/// </summary>
/// <param name="execute">The async action to execute.</param>
/// <param name="canExecute">The predicate to determine if the command can execute.</param>
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter)
{
return !_isExecuting && (_canExecute?.Invoke() ?? true);
}
public async void Execute(object? parameter)
{
if (!CanExecute(parameter))
return;
_isExecuting = true;
RaiseCanExecuteChanged();
try
{
await _execute();
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
/// <summary>
/// Raises the CanExecuteChanged event.
/// </summary>
public void RaiseCanExecuteChanged()
{
_canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
@@ -1,4 +1,5 @@
using System.Windows.Input;
using JdeScoping.SecureStoreManager.Constants;
namespace JdeScoping.SecureStoreManager.ViewModels;
@@ -23,25 +24,41 @@ public class NewStoreDialogViewModel : ViewModelBase
public string StorePath
{
get => _storePath;
set => SetProperty(ref _storePath, value);
set
{
if (SetProperty(ref _storePath, value))
NotifyValidationChanged();
}
}
public string KeyFilePath
{
get => _keyFilePath;
set => SetProperty(ref _keyFilePath, value);
set
{
if (SetProperty(ref _keyFilePath, value))
NotifyValidationChanged();
}
}
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
set
{
if (SetProperty(ref _password, value))
NotifyValidationChanged();
}
}
public string ConfirmPassword
{
get => _confirmPassword;
set => SetProperty(ref _confirmPassword, value);
set
{
if (SetProperty(ref _confirmPassword, value))
NotifyValidationChanged();
}
}
public bool UseKeyFile
@@ -52,6 +69,7 @@ public class NewStoreDialogViewModel : ViewModelBase
if (SetProperty(ref _useKeyFile, value))
{
if (value) UsePassword = false;
NotifyValidationChanged();
}
}
}
@@ -64,10 +82,17 @@ public class NewStoreDialogViewModel : ViewModelBase
if (SetProperty(ref _usePassword, value))
{
if (value) UseKeyFile = false;
NotifyValidationChanged();
}
}
}
private void NotifyValidationChanged()
{
OnPropertyChanged(nameof(IsValid));
OnPropertyChanged(nameof(ValidationError));
}
public ICommand BrowseStorePathCommand { get; }
public ICommand BrowseKeyFilePathCommand { get; }
@@ -93,18 +118,18 @@ public class NewStoreDialogViewModel : ViewModelBase
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return "Store path is required.";
return DialogStrings.StorePathRequired;
if (UseKeyFile && string.IsNullOrWhiteSpace(KeyFilePath))
return "Key file path is required.";
return DialogStrings.KeyFilePathRequired;
if (UsePassword)
{
if (string.IsNullOrWhiteSpace(Password))
return "Password is required.";
return DialogStrings.PasswordRequired;
if (Password != ConfirmPassword)
return "Passwords do not match.";
return DialogStrings.PasswordsDoNotMatch;
}
return null;
@@ -123,7 +148,11 @@ public class NewStoreDialogViewModel : ViewModelBase
if (OnShowSaveFileDialog == null)
return;
var path = await OnShowSaveFileDialog("Choose Store Location", "SecureStore Files", "*.json", ".json");
var path = await OnShowSaveFileDialog(
DialogStrings.ChooseStoreLocation,
FileExtensions.StoreTypeName,
FileExtensions.StorePattern,
FileExtensions.StoreExtension);
if (!string.IsNullOrEmpty(path))
{
StorePath = path;
@@ -135,7 +164,11 @@ public class NewStoreDialogViewModel : ViewModelBase
if (OnShowSaveFileDialog == null)
return;
var path = await OnShowSaveFileDialog("Choose Key File Location", "Key Files", "*.key", ".key");
var path = await OnShowSaveFileDialog(
DialogStrings.ChooseKeyFileLocation,
FileExtensions.KeyTypeName,
FileExtensions.KeyPattern,
FileExtensions.KeyExtension);
if (!string.IsNullOrEmpty(path))
{
KeyFilePath = path;
@@ -163,19 +196,31 @@ public class OpenStoreDialogViewModel : ViewModelBase
public string StorePath
{
get => _storePath;
set => SetProperty(ref _storePath, value);
set
{
if (SetProperty(ref _storePath, value))
NotifyValidationChanged();
}
}
public string KeyFilePath
{
get => _keyFilePath;
set => SetProperty(ref _keyFilePath, value);
set
{
if (SetProperty(ref _keyFilePath, value))
NotifyValidationChanged();
}
}
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
set
{
if (SetProperty(ref _password, value))
NotifyValidationChanged();
}
}
public bool UseKeyFile
@@ -186,6 +231,7 @@ public class OpenStoreDialogViewModel : ViewModelBase
if (SetProperty(ref _useKeyFile, value))
{
if (value) UsePassword = false;
NotifyValidationChanged();
}
}
}
@@ -198,10 +244,17 @@ public class OpenStoreDialogViewModel : ViewModelBase
if (SetProperty(ref _usePassword, value))
{
if (value) UseKeyFile = false;
NotifyValidationChanged();
}
}
}
private void NotifyValidationChanged()
{
OnPropertyChanged(nameof(IsValid));
OnPropertyChanged(nameof(ValidationError));
}
public ICommand BrowseStorePathCommand { get; }
public ICommand BrowseKeyFilePathCommand { get; }
@@ -227,22 +280,22 @@ public class OpenStoreDialogViewModel : ViewModelBase
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return "Store path is required.";
return DialogStrings.StorePathRequired;
if (!System.IO.File.Exists(StorePath))
return "Store file does not exist.";
return DialogStrings.StoreFileNotFound;
if (UseKeyFile)
{
if (string.IsNullOrWhiteSpace(KeyFilePath))
return "Key file path is required.";
return DialogStrings.KeyFilePathRequired;
if (!System.IO.File.Exists(KeyFilePath))
return "Key file does not exist.";
return DialogStrings.KeyFileNotFound;
}
if (UsePassword && string.IsNullOrWhiteSpace(Password))
return "Password is required.";
return DialogStrings.PasswordRequired;
return null;
}
@@ -260,7 +313,10 @@ public class OpenStoreDialogViewModel : ViewModelBase
if (OnShowOpenFileDialog == null)
return;
var path = await OnShowOpenFileDialog("Select Store File", "SecureStore Files", "*.json");
var path = await OnShowOpenFileDialog(
DialogStrings.SelectStoreFile,
FileExtensions.StoreTypeName,
FileExtensions.StorePattern);
if (!string.IsNullOrEmpty(path))
{
StorePath = path;
@@ -272,7 +328,10 @@ public class OpenStoreDialogViewModel : ViewModelBase
if (OnShowOpenFileDialog == null)
return;
var path = await OnShowOpenFileDialog("Select Key File", "Key Files", "*.key");
var path = await OnShowOpenFileDialog(
DialogStrings.SelectKeyFile,
FileExtensions.KeyTypeName,
FileExtensions.KeyPattern);
if (!string.IsNullOrEmpty(path))
{
KeyFilePath = path;
@@ -303,7 +362,11 @@ public class SecretEditDialogViewModel : ViewModelBase
public string Key
{
get => _key;
set => SetProperty(ref _key, value);
set
{
if (SetProperty(ref _key, value))
NotifyValidationChanged();
}
}
public string Value
@@ -329,9 +392,15 @@ public class SecretEditDialogViewModel : ViewModelBase
get
{
if (string.IsNullOrWhiteSpace(Key))
return "Key is required.";
return DialogStrings.KeyRequired;
return null;
}
}
private void NotifyValidationChanged()
{
OnPropertyChanged(nameof(IsValid));
OnPropertyChanged(nameof(ValidationError));
}
}
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using JdeScoping.SecureStoreManager.Constants;
using JdeScoping.SecureStoreManager.Services;
namespace JdeScoping.SecureStoreManager.ViewModels;
@@ -10,33 +11,36 @@ namespace JdeScoping.SecureStoreManager.ViewModels;
public class MainWindowViewModel : ViewModelBase
{
private readonly ISecureStoreManager _storeManager;
private readonly IDialogService _dialogService;
private readonly IClipboardService _clipboardService;
private SecretItemViewModel? _selectedSecret;
private string _statusMessage = "Ready";
public MainWindowViewModel() : this(new Services.SecureStoreManager())
public MainWindowViewModel(
ISecureStoreManager storeManager,
IDialogService dialogService,
IClipboardService clipboardService)
{
}
public MainWindowViewModel(ISecureStoreManager storeManager)
{
_storeManager = storeManager;
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
_dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService));
_clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService));
Secrets = new ObservableCollection<SecretItemViewModel>();
// File commands
NewStoreCommand = new RelayCommand(ExecuteNewStore);
OpenStoreCommand = new RelayCommand(ExecuteOpenStore);
SaveCommand = new RelayCommand(ExecuteSave, CanSave);
CloseStoreCommand = new RelayCommand(ExecuteCloseStore, () => _storeManager.IsStoreOpen);
ExitCommand = new RelayCommand(ExecuteExit);
// File commands (async)
NewStoreCommand = new AsyncRelayCommand(ExecuteNewStoreAsync);
OpenStoreCommand = new AsyncRelayCommand(ExecuteOpenStoreAsync);
SaveCommand = new AsyncRelayCommand(ExecuteSaveAsync, CanSave);
CloseStoreCommand = new AsyncRelayCommand(ExecuteCloseStoreAsync, () => _storeManager.IsStoreOpen);
ExitCommand = new AsyncRelayCommand(ExecuteExitAsync);
// Secret commands
AddSecretCommand = new RelayCommand(ExecuteAddSecret, () => _storeManager.IsStoreOpen);
EditSecretCommand = new RelayCommand(ExecuteEditSecret, CanEditOrDeleteSecret);
DeleteSecretCommand = new RelayCommand(ExecuteDeleteSecret, CanEditOrDeleteSecret);
DeleteSecretCommand = new AsyncRelayCommand(ExecuteDeleteSecretAsync, CanEditOrDeleteSecret);
// Tools commands
GenerateKeyFileCommand = new RelayCommand(ExecuteGenerateKeyFile);
ExportKeyCommand = new RelayCommand(ExecuteExportKey, () => _storeManager.IsStoreOpen);
// Tools commands (async)
GenerateKeyFileCommand = new AsyncRelayCommand(ExecuteGenerateKeyFileAsync);
ExportKeyCommand = new AsyncRelayCommand(ExecuteExportKeyAsync, () => _storeManager.IsStoreOpen);
}
/// <summary>
@@ -50,7 +54,14 @@ public class MainWindowViewModel : ViewModelBase
public SecretItemViewModel? SelectedSecret
{
get => _selectedSecret;
set => SetProperty(ref _selectedSecret, value);
set
{
if (SetProperty(ref _selectedSecret, value))
{
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
}
}
}
/// <summary>
@@ -134,7 +145,9 @@ public class MainWindowViewModel : ViewModelBase
catch (Exception ex)
{
StatusMessage = $"Error creating store: {ex.Message}";
await (OnShowError?.Invoke($"Failed to create store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
await _dialogService.ShowErrorAsync(
string.Format(DialogStrings.FailedToCreateStoreFormat, ex.Message),
DialogStrings.ErrorTitle);
}
}
@@ -166,7 +179,9 @@ public class MainWindowViewModel : ViewModelBase
catch (Exception ex)
{
StatusMessage = $"Error opening store: {ex.Message}";
await (OnShowError?.Invoke($"Failed to open store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
await _dialogService.ShowErrorAsync(
string.Format(DialogStrings.FailedToOpenStoreFormat, ex.Message),
DialogStrings.ErrorTitle);
}
}
@@ -185,7 +200,9 @@ public class MainWindowViewModel : ViewModelBase
catch (Exception ex)
{
StatusMessage = $"Error saving secret: {ex.Message}";
await (OnShowError?.Invoke($"Failed to save secret:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
await _dialogService.ShowErrorAsync(
string.Format(DialogStrings.FailedToSaveSecretFormat, ex.Message),
DialogStrings.ErrorTitle);
}
}
@@ -198,10 +215,7 @@ public class MainWindowViewModel : ViewModelBase
if (!_storeManager.HasUnsavedChanges)
return true;
if (OnShowUnsavedChangesPrompt == null)
return true;
var result = await OnShowUnsavedChangesPrompt();
var result = await _dialogService.ShowUnsavedChangesPromptAsync();
switch (result)
{
@@ -216,7 +230,7 @@ public class MainWindowViewModel : ViewModelBase
}
}
private async void ExecuteNewStore()
private async Task ExecuteNewStoreAsync()
{
if (!await PromptForUnsavedChangesAsync())
return;
@@ -225,7 +239,7 @@ public class MainWindowViewModel : ViewModelBase
OnRequestNewStoreDialog?.Invoke();
}
private async void ExecuteOpenStore()
private async Task ExecuteOpenStoreAsync()
{
if (!await PromptForUnsavedChangesAsync())
return;
@@ -234,11 +248,6 @@ public class MainWindowViewModel : ViewModelBase
OnRequestOpenStoreDialog?.Invoke();
}
private async void ExecuteSave()
{
await ExecuteSaveAsync();
}
private async Task ExecuteSaveAsync()
{
try
@@ -250,13 +259,15 @@ public class MainWindowViewModel : ViewModelBase
catch (Exception ex)
{
StatusMessage = $"Error saving: {ex.Message}";
await (OnShowError?.Invoke($"Failed to save store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
await _dialogService.ShowErrorAsync(
string.Format(DialogStrings.FailedToSaveStoreFormat, ex.Message),
DialogStrings.ErrorTitle);
}
}
private bool CanSave() => _storeManager.IsStoreOpen && _storeManager.HasUnsavedChanges;
private async void ExecuteCloseStore()
private async Task ExecuteCloseStoreAsync()
{
if (!await PromptForUnsavedChangesAsync())
return;
@@ -267,7 +278,7 @@ public class MainWindowViewModel : ViewModelBase
StatusMessage = "Store closed";
}
private async void ExecuteExit()
private async Task ExecuteExitAsync()
{
if (!await PromptForUnsavedChangesAsync())
return;
@@ -290,15 +301,13 @@ public class MainWindowViewModel : ViewModelBase
OnRequestEditSecretDialog?.Invoke(SelectedSecret.Key, SelectedSecret.ActualValue);
}
private async void ExecuteDeleteSecret()
private async Task ExecuteDeleteSecretAsync()
{
if (SelectedSecret == null)
return;
if (OnShowDeleteConfirmation == null)
return;
var confirmed = await OnShowDeleteConfirmation(SelectedSecret.Key);
var confirmMessage = string.Format(DialogStrings.ConfirmDeleteFormat, SelectedSecret.Key);
var confirmed = await _dialogService.ShowConfirmationAsync(confirmMessage, DialogStrings.ConfirmDeleteTitle);
if (!confirmed)
return;
@@ -313,18 +322,22 @@ public class MainWindowViewModel : ViewModelBase
catch (Exception ex)
{
StatusMessage = $"Error deleting secret: {ex.Message}";
await (OnShowError?.Invoke($"Failed to delete secret:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
await _dialogService.ShowErrorAsync(
string.Format(DialogStrings.FailedToDeleteSecretFormat, ex.Message),
DialogStrings.ErrorTitle);
}
}
private bool CanEditOrDeleteSecret() => _storeManager.IsStoreOpen && SelectedSecret != null;
private async void ExecuteGenerateKeyFile()
private async Task ExecuteGenerateKeyFileAsync()
{
if (OnShowSaveFileDialog == null)
return;
var filePath = await _dialogService.ShowSaveFileDialogAsync(
DialogStrings.GenerateKeyFileTitle,
FileExtensions.KeyTypeName,
FileExtensions.KeyPattern,
FileExtensions.KeyExtension);
var filePath = await OnShowSaveFileDialog("Generate Key File", "Key Files", "*.key", ".key");
if (string.IsNullOrEmpty(filePath))
return;
@@ -332,21 +345,27 @@ public class MainWindowViewModel : ViewModelBase
{
_storeManager.GenerateKeyFile(filePath);
StatusMessage = $"Generated key file: {filePath}";
await (OnShowInfo?.Invoke($"Key file generated successfully:\n\n{filePath}", "Key Generated") ?? Task.CompletedTask);
await _dialogService.ShowInfoAsync(
string.Format(DialogStrings.KeyFileGeneratedFormat, filePath),
DialogStrings.KeyGeneratedTitle);
}
catch (Exception ex)
{
StatusMessage = $"Error generating key: {ex.Message}";
await (OnShowError?.Invoke($"Failed to generate key file:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
await _dialogService.ShowErrorAsync(
string.Format(DialogStrings.FailedToGenerateKeyFormat, ex.Message),
DialogStrings.ErrorTitle);
}
}
private async void ExecuteExportKey()
private async Task ExecuteExportKeyAsync()
{
if (OnShowSaveFileDialog == null)
return;
var filePath = await _dialogService.ShowSaveFileDialogAsync(
DialogStrings.ExportKeyTitle,
FileExtensions.KeyTypeName,
FileExtensions.KeyPattern,
FileExtensions.KeyExtension);
var filePath = await OnShowSaveFileDialog("Export Key", "Key Files", "*.key", ".key");
if (string.IsNullOrEmpty(filePath))
return;
@@ -354,12 +373,16 @@ public class MainWindowViewModel : ViewModelBase
{
_storeManager.ExportKey(filePath);
StatusMessage = $"Exported key to: {filePath}";
await (OnShowInfo?.Invoke($"Key exported successfully:\n\n{filePath}", "Key Exported") ?? Task.CompletedTask);
await _dialogService.ShowInfoAsync(
string.Format(DialogStrings.KeyExportedFormat, filePath),
DialogStrings.KeyExportedTitle);
}
catch (Exception ex)
{
StatusMessage = $"Error exporting key: {ex.Message}";
await (OnShowError?.Invoke($"Failed to export key:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
await _dialogService.ShowErrorAsync(
string.Format(DialogStrings.FailedToExportKeyFormat, ex.Message),
DialogStrings.ErrorTitle);
}
}
@@ -372,7 +395,7 @@ public class MainWindowViewModel : ViewModelBase
foreach (var key in _storeManager.GetKeys())
{
var value = _storeManager.GetSecret(key);
Secrets.Add(new SecretItemViewModel(key, value));
Secrets.Add(new SecretItemViewModel(key, value, _clipboardService));
}
}
@@ -383,35 +406,18 @@ public class MainWindowViewModel : ViewModelBase
OnPropertyChanged(nameof(WindowTitle));
// Manually raise CanExecuteChanged for all commands
(SaveCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CloseStoreCommand as RelayCommand)?.RaiseCanExecuteChanged();
(SaveCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
(CloseStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
(AddSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(DeleteSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(ExportKeyCommand as RelayCommand)?.RaiseCanExecuteChanged();
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
(ExportKeyCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
}
// Events for view to show dialogs (sync)
// Events for view to show dialogs (these require view-specific DataContext setup)
public event Action? OnRequestNewStoreDialog;
public event Action? OnRequestOpenStoreDialog;
public event Action? OnRequestAddSecretDialog;
public event Action<string, string>? OnRequestEditSecretDialog;
public event Action? OnRequestClose;
// Events for view to show dialogs (async)
public event Func<string, string, Task>? OnShowError;
public event Func<string, string, Task>? OnShowInfo;
public event Func<Task<UnsavedChangesResult>>? OnShowUnsavedChangesPrompt;
public event Func<string, Task<bool>>? OnShowDeleteConfirmation;
public event Func<string, string, string, string, Task<string?>>? OnShowSaveFileDialog;
}
/// <summary>
/// Result from unsaved changes prompt.
/// </summary>
public enum UnsavedChangesResult
{
Save,
DontSave,
Cancel
}
@@ -1,4 +1,5 @@
using System.Windows.Input;
using JdeScoping.SecureStoreManager.Services;
namespace JdeScoping.SecureStoreManager.ViewModels;
@@ -8,13 +9,15 @@ namespace JdeScoping.SecureStoreManager.ViewModels;
public class SecretItemViewModel : ViewModelBase
{
private readonly string _actualValue;
private readonly IClipboardService _clipboardService;
private bool _isValueVisible;
private const string MaskedValue = "********";
public SecretItemViewModel(string key, string value)
public SecretItemViewModel(string key, string value, IClipboardService clipboardService)
{
Key = key;
_actualValue = value;
_clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService));
ToggleVisibilityCommand = new RelayCommand(ToggleVisibility);
CopyToClipboardCommand = new RelayCommand(CopyToClipboard);
}
@@ -60,10 +63,9 @@ public class SecretItemViewModel : ViewModelBase
public ICommand CopyToClipboardCommand { get; }
/// <summary>
/// Event raised when clipboard copy is requested.
/// The view subscribes to this to perform the actual clipboard operation.
/// Event raised when clipboard copy fails.
/// </summary>
public event Func<string, Task>? OnCopyToClipboard;
public event Action<string>? OnCopyFailed;
private void ToggleVisibility()
{
@@ -74,14 +76,11 @@ public class SecretItemViewModel : ViewModelBase
{
try
{
if (OnCopyToClipboard != null)
{
await OnCopyToClipboard(_actualValue);
}
await _clipboardService.SetTextAsync(_actualValue);
}
catch
catch (Exception ex)
{
// Clipboard operations can fail in some scenarios
OnCopyFailed?.Invoke($"Failed to copy to clipboard: {ex.Message}");
}
}
}
@@ -6,9 +6,7 @@
Height="500" Width="800"
MinHeight="400" MinWidth="600"
WindowStartupLocation="CenterScreen">
<Window.DataContext>
<vm:MainWindowViewModel />
</Window.DataContext>
<!-- DataContext is set via DI in App.axaml.cs -->
<Window.KeyBindings>
<KeyBinding Gesture="Ctrl+N" Command="{Binding NewStoreCommand}" />
@@ -1,16 +1,13 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using JdeScoping.SecureStoreManager.ViewModels;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
namespace JdeScoping.SecureStoreManager.Views;
public partial class MainWindow : Window
{
private MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!;
private MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel;
public MainWindow()
{
@@ -21,35 +18,22 @@ public partial class MainWindow : Window
private void MainWindow_Loaded(object? sender, RoutedEventArgs e)
{
// Subscribe to dialog request events
if (ViewModel == null)
return;
// Subscribe to dialog request events (these open dialogs with their own DataContext)
ViewModel.OnRequestNewStoreDialog += ShowNewStoreDialog;
ViewModel.OnRequestOpenStoreDialog += ShowOpenStoreDialog;
ViewModel.OnRequestAddSecretDialog += ShowAddSecretDialog;
ViewModel.OnRequestEditSecretDialog += ShowEditSecretDialog;
ViewModel.OnRequestClose += () => Close();
// Subscribe to async dialog events
ViewModel.OnShowError += ShowErrorAsync;
ViewModel.OnShowInfo += ShowInfoAsync;
ViewModel.OnShowUnsavedChangesPrompt += ShowUnsavedChangesPromptAsync;
ViewModel.OnShowDeleteConfirmation += ShowDeleteConfirmationAsync;
ViewModel.OnShowSaveFileDialog += ShowSaveFileDialogAsync;
// Subscribe to clipboard events for secrets
ViewModel.Secrets.CollectionChanged += (s, e) =>
{
if (e.NewItems != null)
{
foreach (SecretItemViewModel secret in e.NewItems)
{
secret.OnCopyToClipboard += CopyToClipboardAsync;
}
}
};
}
private async void MainWindow_Closing(object? sender, WindowClosingEventArgs e)
{
if (ViewModel == null)
return;
e.Cancel = true;
if (await ViewModel.PromptForUnsavedChangesAsync())
{
@@ -59,7 +43,7 @@ public partial class MainWindow : Window
private void DataGrid_DoubleTapped(object? sender, TappedEventArgs e)
{
if (ViewModel.SelectedSecret != null)
if (ViewModel?.SelectedSecret != null)
{
ViewModel.EditSecretCommand.Execute(null);
}
@@ -67,6 +51,8 @@ public partial class MainWindow : Window
private async void ShowNewStoreDialog()
{
if (ViewModel == null) return;
var dialog = new NewStoreDialog();
var result = await dialog.ShowDialog<bool?>(this);
if (result == true)
@@ -81,6 +67,8 @@ public partial class MainWindow : Window
private async void ShowOpenStoreDialog()
{
if (ViewModel == null) return;
var dialog = new OpenStoreDialog();
var result = await dialog.ShowDialog<bool?>(this);
if (result == true)
@@ -95,6 +83,8 @@ public partial class MainWindow : Window
private async void ShowAddSecretDialog()
{
if (ViewModel == null) return;
var dialog = new SecretEditDialog();
var result = await dialog.ShowDialog<bool?>(this);
if (result == true)
@@ -106,6 +96,8 @@ public partial class MainWindow : Window
private async void ShowEditSecretDialog(string key, string value)
{
if (ViewModel == null) return;
var dialog = new SecretEditDialog(key, value);
var result = await dialog.ShowDialog<bool?>(this);
if (result == true)
@@ -114,74 +106,4 @@ public partial class MainWindow : Window
await ViewModel.SaveSecretAsync(vm.Key, vm.Value, isNew: false);
}
}
private async Task ShowErrorAsync(string message, string title)
{
var box = MessageBoxManager
.GetMessageBoxStandard(title, message, ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await box.ShowWindowDialogAsync(this);
}
private async Task ShowInfoAsync(string message, string title)
{
var box = MessageBoxManager
.GetMessageBoxStandard(title, message, ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Info);
await box.ShowWindowDialogAsync(this);
}
private async Task<UnsavedChangesResult> ShowUnsavedChangesPromptAsync()
{
var box = MessageBoxManager
.GetMessageBoxStandard(
"Unsaved Changes",
"You have unsaved changes. Do you want to save before continuing?",
ButtonEnum.YesNoCancel,
MsBox.Avalonia.Enums.Icon.Warning);
var result = await box.ShowWindowDialogAsync(this);
return result switch
{
ButtonResult.Yes => UnsavedChangesResult.Save,
ButtonResult.No => UnsavedChangesResult.DontSave,
_ => UnsavedChangesResult.Cancel
};
}
private async Task<bool> ShowDeleteConfirmationAsync(string key)
{
var box = MessageBoxManager
.GetMessageBoxStandard(
"Confirm Delete",
$"Are you sure you want to delete the secret '{key}'?\n\nThis action cannot be undone.",
ButtonEnum.YesNo,
MsBox.Avalonia.Enums.Icon.Warning);
var result = await box.ShowWindowDialogAsync(this);
return result == ButtonResult.Yes;
}
private async Task<string?> ShowSaveFileDialogAsync(string title, string fileTypeName, string pattern, string defaultExtension)
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = title,
DefaultExtension = defaultExtension,
FileTypeChoices = new[]
{
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } }
}
});
return file?.Path.LocalPath;
}
private async Task CopyToClipboardAsync(string text)
{
if (Clipboard != null)
{
await Clipboard.SetTextAsync(text);
}
}
}
@@ -7,9 +7,7 @@
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<Window.DataContext>
<vm:NewStoreDialogViewModel />
</Window.DataContext>
<!-- DataContext is set in code-behind -->
<Grid Margin="15" RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- Store Path -->
@@ -93,7 +91,7 @@
<!-- Buttons -->
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Content="Create" Click="CreateButton_Click" MinWidth="80" Padding="10,5" />
<Button Content="Create" Click="CreateButton_Click" IsEnabled="{Binding IsValid}" MinWidth="80" Padding="10,5" />
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
</StackPanel>
</Grid>
@@ -1,6 +1,7 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using JdeScoping.SecureStoreManager.Constants;
using JdeScoping.SecureStoreManager.ViewModels;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
@@ -14,6 +15,7 @@ public partial class NewStoreDialog : Window
public NewStoreDialog()
{
InitializeComponent();
DataContext = new NewStoreDialogViewModel();
Loaded += NewStoreDialog_Loaded;
}
@@ -31,7 +33,7 @@ public partial class NewStoreDialog : Window
FileTypeChoices = new[]
{
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } }
new FilePickerFileType(FileExtensions.AllFilesTypeName) { Patterns = new[] { FileExtensions.AllFilesPattern } }
}
});
@@ -44,8 +46,8 @@ public partial class NewStoreDialog : Window
{
var box = MessageBoxManager
.GetMessageBoxStandard(
"Validation Error",
ViewModel.ValidationError ?? "Please fill in all required fields.",
DialogStrings.ValidationErrorTitle,
ViewModel.ValidationError ?? DialogStrings.DefaultValidationError,
ButtonEnum.Ok,
MsBox.Avalonia.Enums.Icon.Warning);
await box.ShowWindowDialogAsync(this);
@@ -7,9 +7,7 @@
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<Window.DataContext>
<vm:OpenStoreDialogViewModel />
</Window.DataContext>
<!-- DataContext is set in code-behind -->
<Grid Margin="15" RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- Store Path -->
@@ -87,7 +85,7 @@
<!-- Buttons -->
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Content="Open" Click="OpenButton_Click" MinWidth="80" Padding="10,5" />
<Button Content="Open" Click="OpenButton_Click" IsEnabled="{Binding IsValid}" MinWidth="80" Padding="10,5" />
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
</StackPanel>
</Grid>
@@ -1,6 +1,7 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using JdeScoping.SecureStoreManager.Constants;
using JdeScoping.SecureStoreManager.ViewModels;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
@@ -14,6 +15,7 @@ public partial class OpenStoreDialog : Window
public OpenStoreDialog()
{
InitializeComponent();
DataContext = new OpenStoreDialogViewModel();
Loaded += OpenStoreDialog_Loaded;
}
@@ -31,7 +33,7 @@ public partial class OpenStoreDialog : Window
FileTypeFilter = new[]
{
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } }
new FilePickerFileType(FileExtensions.AllFilesTypeName) { Patterns = new[] { FileExtensions.AllFilesPattern } }
}
});
@@ -44,8 +46,8 @@ public partial class OpenStoreDialog : Window
{
var box = MessageBoxManager
.GetMessageBoxStandard(
"Validation Error",
ViewModel.ValidationError ?? "Please fill in all required fields.",
DialogStrings.ValidationErrorTitle,
ViewModel.ValidationError ?? DialogStrings.DefaultValidationError,
ButtonEnum.Ok,
MsBox.Avalonia.Enums.Icon.Warning);
await box.ShowWindowDialogAsync(this);
@@ -7,9 +7,7 @@
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<Window.DataContext>
<vm:SecretEditDialogViewModel />
</Window.DataContext>
<!-- DataContext is set in code-behind -->
<Grid Margin="15" RowDefinitions="Auto,Auto,*,Auto">
<!-- Key -->
@@ -43,7 +41,7 @@
<!-- Buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Content="Save" Click="SaveButton_Click" MinWidth="80" Padding="10,5" />
<Button Content="Save" Click="SaveButton_Click" IsEnabled="{Binding IsValid}" MinWidth="80" Padding="10,5" />
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
</StackPanel>
</Grid>
@@ -13,10 +13,12 @@ public partial class SecretEditDialog : Window
public SecretEditDialog()
{
InitializeComponent();
DataContext = new SecretEditDialogViewModel();
}
public SecretEditDialog(string key, string value) : this()
public SecretEditDialog(string key, string value)
{
InitializeComponent();
DataContext = new SecretEditDialogViewModel(key, value);
}