fbe58a81e4
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
424 lines
14 KiB
C#
424 lines
14 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Windows.Input;
|
|
using JdeScoping.SecureStoreManager.Constants;
|
|
using JdeScoping.SecureStoreManager.Services;
|
|
|
|
namespace JdeScoping.SecureStoreManager.ViewModels;
|
|
|
|
/// <summary>
|
|
/// Main window view model containing all application logic.
|
|
/// </summary>
|
|
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(
|
|
ISecureStoreManager storeManager,
|
|
IDialogService dialogService,
|
|
IClipboardService clipboardService)
|
|
{
|
|
_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 (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 AsyncRelayCommand(ExecuteDeleteSecretAsync, CanEditOrDeleteSecret);
|
|
|
|
// Tools commands (async)
|
|
GenerateKeyFileCommand = new AsyncRelayCommand(ExecuteGenerateKeyFileAsync);
|
|
ExportKeyCommand = new AsyncRelayCommand(ExecuteExportKeyAsync, () => _storeManager.IsStoreOpen);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the collection of secrets in the current store.
|
|
/// </summary>
|
|
public ObservableCollection<SecretItemViewModel> Secrets { get; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the currently selected secret.
|
|
/// </summary>
|
|
public SecretItemViewModel? SelectedSecret
|
|
{
|
|
get => _selectedSecret;
|
|
set
|
|
{
|
|
if (SetProperty(ref _selectedSecret, value))
|
|
{
|
|
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
|
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current status message.
|
|
/// </summary>
|
|
public string StatusMessage
|
|
{
|
|
get => _statusMessage;
|
|
private set => SetProperty(ref _statusMessage, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the window title including the current store path.
|
|
/// </summary>
|
|
public string WindowTitle
|
|
{
|
|
get
|
|
{
|
|
var title = "SecureStore Manager";
|
|
if (_storeManager.IsStoreOpen)
|
|
{
|
|
title += $" - {_storeManager.CurrentStorePath}";
|
|
if (_storeManager.HasUnsavedChanges)
|
|
title += " *";
|
|
}
|
|
return title;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets whether a store is currently open.
|
|
/// </summary>
|
|
public bool IsStoreOpen => _storeManager.IsStoreOpen;
|
|
|
|
/// <summary>
|
|
/// Gets whether there are unsaved changes.
|
|
/// </summary>
|
|
public bool HasUnsavedChanges => _storeManager.HasUnsavedChanges;
|
|
|
|
// File Commands
|
|
public ICommand NewStoreCommand { get; }
|
|
public ICommand OpenStoreCommand { get; }
|
|
public ICommand SaveCommand { get; }
|
|
public ICommand CloseStoreCommand { get; }
|
|
public ICommand ExitCommand { get; }
|
|
|
|
// Secret Commands
|
|
public ICommand AddSecretCommand { get; }
|
|
public ICommand EditSecretCommand { get; }
|
|
public ICommand DeleteSecretCommand { get; }
|
|
|
|
// Tools Commands
|
|
public ICommand GenerateKeyFileCommand { get; }
|
|
public ICommand ExportKeyCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Creates a new store. Called by the dialog.
|
|
/// </summary>
|
|
public async Task CreateNewStoreAsync(string storePath, string? keyFilePath, string? password)
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(keyFilePath))
|
|
{
|
|
_storeManager.CreateStore(storePath, keyFilePath);
|
|
StatusMessage = $"Created store with key file: {keyFilePath}";
|
|
}
|
|
else if (!string.IsNullOrEmpty(password))
|
|
{
|
|
_storeManager.CreateStoreWithPassword(storePath, password);
|
|
StatusMessage = "Created password-protected store";
|
|
}
|
|
else
|
|
{
|
|
throw new ArgumentException("Either key file path or password must be provided.");
|
|
}
|
|
|
|
RefreshSecrets();
|
|
NotifyStoreChanged();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error creating store: {ex.Message}";
|
|
await _dialogService.ShowErrorAsync(
|
|
string.Format(DialogStrings.FailedToCreateStoreFormat, ex.Message),
|
|
DialogStrings.ErrorTitle);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens an existing store. Called by the dialog.
|
|
/// </summary>
|
|
public async Task OpenExistingStoreAsync(string storePath, string? keyFilePath, string? password)
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(keyFilePath))
|
|
{
|
|
_storeManager.OpenStore(storePath, keyFilePath);
|
|
StatusMessage = "Opened store with key file";
|
|
}
|
|
else if (!string.IsNullOrEmpty(password))
|
|
{
|
|
_storeManager.OpenStoreWithPassword(storePath, password);
|
|
StatusMessage = "Opened password-protected store";
|
|
}
|
|
else
|
|
{
|
|
throw new ArgumentException("Either key file path or password must be provided.");
|
|
}
|
|
|
|
RefreshSecrets();
|
|
NotifyStoreChanged();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error opening store: {ex.Message}";
|
|
await _dialogService.ShowErrorAsync(
|
|
string.Format(DialogStrings.FailedToOpenStoreFormat, ex.Message),
|
|
DialogStrings.ErrorTitle);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds or updates a secret. Called by the dialog.
|
|
/// </summary>
|
|
public async Task SaveSecretAsync(string key, string value, bool isNew)
|
|
{
|
|
try
|
|
{
|
|
_storeManager.SetSecret(key, value);
|
|
RefreshSecrets();
|
|
NotifyStoreChanged();
|
|
StatusMessage = isNew ? $"Added secret: {key}" : $"Updated secret: {key}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error saving secret: {ex.Message}";
|
|
await _dialogService.ShowErrorAsync(
|
|
string.Format(DialogStrings.FailedToSaveSecretFormat, ex.Message),
|
|
DialogStrings.ErrorTitle);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks for unsaved changes and prompts the user.
|
|
/// </summary>
|
|
/// <returns>True if it's safe to proceed, false if the user cancelled.</returns>
|
|
public async Task<bool> PromptForUnsavedChangesAsync()
|
|
{
|
|
if (!_storeManager.HasUnsavedChanges)
|
|
return true;
|
|
|
|
var result = await _dialogService.ShowUnsavedChangesPromptAsync();
|
|
|
|
switch (result)
|
|
{
|
|
case UnsavedChangesResult.Save:
|
|
await ExecuteSaveAsync();
|
|
return true;
|
|
case UnsavedChangesResult.DontSave:
|
|
return true;
|
|
case UnsavedChangesResult.Cancel:
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task ExecuteNewStoreAsync()
|
|
{
|
|
if (!await PromptForUnsavedChangesAsync())
|
|
return;
|
|
|
|
// The view will show the NewStoreDialog
|
|
OnRequestNewStoreDialog?.Invoke();
|
|
}
|
|
|
|
private async Task ExecuteOpenStoreAsync()
|
|
{
|
|
if (!await PromptForUnsavedChangesAsync())
|
|
return;
|
|
|
|
// The view will show the OpenStoreDialog
|
|
OnRequestOpenStoreDialog?.Invoke();
|
|
}
|
|
|
|
private async Task ExecuteSaveAsync()
|
|
{
|
|
try
|
|
{
|
|
_storeManager.Save();
|
|
NotifyStoreChanged();
|
|
StatusMessage = "Store saved";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error saving: {ex.Message}";
|
|
await _dialogService.ShowErrorAsync(
|
|
string.Format(DialogStrings.FailedToSaveStoreFormat, ex.Message),
|
|
DialogStrings.ErrorTitle);
|
|
}
|
|
}
|
|
|
|
private bool CanSave() => _storeManager.IsStoreOpen && _storeManager.HasUnsavedChanges;
|
|
|
|
private async Task ExecuteCloseStoreAsync()
|
|
{
|
|
if (!await PromptForUnsavedChangesAsync())
|
|
return;
|
|
|
|
_storeManager.CloseStore();
|
|
Secrets.Clear();
|
|
NotifyStoreChanged();
|
|
StatusMessage = "Store closed";
|
|
}
|
|
|
|
private async Task ExecuteExitAsync()
|
|
{
|
|
if (!await PromptForUnsavedChangesAsync())
|
|
return;
|
|
|
|
OnRequestClose?.Invoke();
|
|
}
|
|
|
|
private void ExecuteAddSecret()
|
|
{
|
|
// The view will show the SecretEditDialog
|
|
OnRequestAddSecretDialog?.Invoke();
|
|
}
|
|
|
|
private void ExecuteEditSecret()
|
|
{
|
|
if (SelectedSecret == null)
|
|
return;
|
|
|
|
// The view will show the SecretEditDialog with existing values
|
|
OnRequestEditSecretDialog?.Invoke(SelectedSecret.Key, SelectedSecret.ActualValue);
|
|
}
|
|
|
|
private async Task ExecuteDeleteSecretAsync()
|
|
{
|
|
if (SelectedSecret == null)
|
|
return;
|
|
|
|
var confirmMessage = string.Format(DialogStrings.ConfirmDeleteFormat, SelectedSecret.Key);
|
|
var confirmed = await _dialogService.ShowConfirmationAsync(confirmMessage, DialogStrings.ConfirmDeleteTitle);
|
|
if (!confirmed)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var key = SelectedSecret.Key;
|
|
_storeManager.RemoveSecret(key);
|
|
RefreshSecrets();
|
|
NotifyStoreChanged();
|
|
StatusMessage = $"Deleted secret: {key}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error deleting secret: {ex.Message}";
|
|
await _dialogService.ShowErrorAsync(
|
|
string.Format(DialogStrings.FailedToDeleteSecretFormat, ex.Message),
|
|
DialogStrings.ErrorTitle);
|
|
}
|
|
}
|
|
|
|
private bool CanEditOrDeleteSecret() => _storeManager.IsStoreOpen && SelectedSecret != null;
|
|
|
|
private async Task ExecuteGenerateKeyFileAsync()
|
|
{
|
|
var filePath = await _dialogService.ShowSaveFileDialogAsync(
|
|
DialogStrings.GenerateKeyFileTitle,
|
|
FileExtensions.KeyTypeName,
|
|
FileExtensions.KeyPattern,
|
|
FileExtensions.KeyExtension);
|
|
|
|
if (string.IsNullOrEmpty(filePath))
|
|
return;
|
|
|
|
try
|
|
{
|
|
_storeManager.GenerateKeyFile(filePath);
|
|
StatusMessage = $"Generated key file: {filePath}";
|
|
await _dialogService.ShowInfoAsync(
|
|
string.Format(DialogStrings.KeyFileGeneratedFormat, filePath),
|
|
DialogStrings.KeyGeneratedTitle);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error generating key: {ex.Message}";
|
|
await _dialogService.ShowErrorAsync(
|
|
string.Format(DialogStrings.FailedToGenerateKeyFormat, ex.Message),
|
|
DialogStrings.ErrorTitle);
|
|
}
|
|
}
|
|
|
|
private async Task ExecuteExportKeyAsync()
|
|
{
|
|
var filePath = await _dialogService.ShowSaveFileDialogAsync(
|
|
DialogStrings.ExportKeyTitle,
|
|
FileExtensions.KeyTypeName,
|
|
FileExtensions.KeyPattern,
|
|
FileExtensions.KeyExtension);
|
|
|
|
if (string.IsNullOrEmpty(filePath))
|
|
return;
|
|
|
|
try
|
|
{
|
|
_storeManager.ExportKey(filePath);
|
|
StatusMessage = $"Exported key to: {filePath}";
|
|
await _dialogService.ShowInfoAsync(
|
|
string.Format(DialogStrings.KeyExportedFormat, filePath),
|
|
DialogStrings.KeyExportedTitle);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error exporting key: {ex.Message}";
|
|
await _dialogService.ShowErrorAsync(
|
|
string.Format(DialogStrings.FailedToExportKeyFormat, ex.Message),
|
|
DialogStrings.ErrorTitle);
|
|
}
|
|
}
|
|
|
|
private void RefreshSecrets()
|
|
{
|
|
Secrets.Clear();
|
|
if (!_storeManager.IsStoreOpen)
|
|
return;
|
|
|
|
foreach (var key in _storeManager.GetKeys())
|
|
{
|
|
var value = _storeManager.GetSecret(key);
|
|
Secrets.Add(new SecretItemViewModel(key, value, _clipboardService));
|
|
}
|
|
}
|
|
|
|
private void NotifyStoreChanged()
|
|
{
|
|
OnPropertyChanged(nameof(IsStoreOpen));
|
|
OnPropertyChanged(nameof(HasUnsavedChanges));
|
|
OnPropertyChanged(nameof(WindowTitle));
|
|
|
|
// Manually raise CanExecuteChanged for all commands
|
|
(SaveCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
(CloseStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
(AddSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
|
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
|
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
(ExportKeyCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
}
|
|
|
|
// 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;
|
|
}
|