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:
@@ -0,0 +1,853 @@
|
|||||||
|
# SecureStoreManager Code Review
|
||||||
|
|
||||||
|
**Review Date:** January 19, 2026
|
||||||
|
**Reviewer:** Claude Code
|
||||||
|
**Project Path:** `NEW/src/Utils/JdeScoping.SecureStoreManager/`
|
||||||
|
**Focus:** Best Practices, CLEAN Architecture, Code Quality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The SecureStoreManager is an Avalonia desktop application that provides a GUI for managing encrypted secrets using the NeoSmart.SecureStore library. The codebase demonstrates solid MVVM fundamentals with readable, well-organized code. However, several architectural concerns limit testability, maintainability, and robustness.
|
||||||
|
|
||||||
|
### Overall Assessment: **B-** → **B+** (Post-Resolution)
|
||||||
|
|
||||||
|
| Category | Grade | Post-Fix | Summary |
|
||||||
|
|----------|-------|----------|---------|
|
||||||
|
| Architecture | C+ | B+ | ✅ Added use-case layer (StoreUseCases, SecretUseCases) |
|
||||||
|
| MVVM Implementation | B | B+ | ✅ Fixed async patterns, CanExecute refresh |
|
||||||
|
| Service Layer | B- | A- | ✅ Reserved key protection, logging added |
|
||||||
|
| Code Quality | B | B+ | ✅ AsyncRelayCommand, proper exception handling |
|
||||||
|
| Testability | C | B | ✅ DI container, constructor injection |
|
||||||
|
| Error Handling | C+ | B+ | ✅ Exception surfacing, structured logging |
|
||||||
|
|
||||||
|
### Resolution Status: **ALL FINDINGS ADDRESSED** ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Analysis
|
||||||
|
|
||||||
|
### 1.1 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
JdeScoping.SecureStoreManager/
|
||||||
|
├── Program.cs # Entry point
|
||||||
|
├── App.axaml(.cs) # Application bootstrapping
|
||||||
|
├── Models/
|
||||||
|
│ └── SecretEntry.cs # Domain model (unused)
|
||||||
|
├── Services/
|
||||||
|
│ ├── ISecureStoreManager.cs
|
||||||
|
│ └── SecureStoreManager.cs
|
||||||
|
├── ViewModels/
|
||||||
|
│ ├── ViewModelBase.cs
|
||||||
|
│ ├── RelayCommand.cs
|
||||||
|
│ ├── MainWindowViewModel.cs
|
||||||
|
│ ├── DialogViewModels.cs
|
||||||
|
│ └── SecretItemViewModel.cs
|
||||||
|
├── Views/
|
||||||
|
│ ├── MainWindow.axaml(.cs)
|
||||||
|
│ ├── NewStoreDialog.axaml(.cs)
|
||||||
|
│ ├── OpenStoreDialog.axaml(.cs)
|
||||||
|
│ └── SecretEditDialog.axaml(.cs)
|
||||||
|
└── Converters/
|
||||||
|
└── BooleanConverters.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Current Layering Issues
|
||||||
|
|
||||||
|
**Issue: Flat, UI-Centric Architecture**
|
||||||
|
|
||||||
|
The current architecture lacks separation between application logic and presentation:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Views (Avalonia) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ ViewModels │ ← Contains business logic
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Services (SecureStore) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation: Introduce Application Layer**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Views (Avalonia) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ ViewModels │ ← Presentation logic only
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Application Services (Use Cases) │ ← Business logic
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Infrastructure Services │ ← SecureStore wrapper
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Unused Domain Model
|
||||||
|
|
||||||
|
**Location:** `Models/SecretEntry.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class SecretEntry
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** This model is defined but never used. ViewModels construct `SecretItemViewModel` directly from service data.
|
||||||
|
|
||||||
|
**Recommendation:** Either:
|
||||||
|
- Remove `SecretEntry.cs` to reduce dead code
|
||||||
|
- Use it as the domain model returned by `ISecureStoreManager` for proper layering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MVVM Implementation
|
||||||
|
|
||||||
|
### 2.1 Strengths
|
||||||
|
|
||||||
|
- **ViewModelBase:** Clean implementation of `INotifyPropertyChanged` with `SetProperty<T>` helper
|
||||||
|
- **RelayCommand:** Standard, readable command implementation
|
||||||
|
- **Data Binding:** Proper use of XAML bindings for commands and properties
|
||||||
|
- **Separation:** ViewModels don't directly reference Avalonia types
|
||||||
|
|
||||||
|
### 2.2 MVVM Purity Trade-offs
|
||||||
|
|
||||||
|
**Location:** `Views/MainWindow.axaml.cs` (Lines 22-48)
|
||||||
|
|
||||||
|
The code-behind subscribes to ViewModel events for platform interactions:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void MainWindow_Loaded(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ViewModel.OnRequestNewStoreDialog += ShowNewStoreDialog;
|
||||||
|
ViewModel.OnRequestOpenStoreDialog += ShowOpenStoreDialog;
|
||||||
|
ViewModel.OnShowError += ShowErrorAsync;
|
||||||
|
ViewModel.OnShowSaveFileDialog += ShowSaveFileDialogAsync;
|
||||||
|
// ... more subscriptions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assessment:** This is a pragmatic approach in Avalonia, but it:
|
||||||
|
- Couples ViewModel to specific event signatures
|
||||||
|
- Makes the View responsible for orchestrating dialogs
|
||||||
|
- Prevents easy testing of dialog workflows
|
||||||
|
|
||||||
|
**Recommendation:** Abstract platform services behind interfaces:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Services/IDialogService.cs
|
||||||
|
public interface IDialogService
|
||||||
|
{
|
||||||
|
Task<string?> ShowSaveFileDialogAsync(string title, string filter, string extension);
|
||||||
|
Task<string?> ShowOpenFileDialogAsync(string title, string filter);
|
||||||
|
Task ShowErrorAsync(string message, string title);
|
||||||
|
Task ShowInfoAsync(string message, string title);
|
||||||
|
Task<bool> ShowConfirmationAsync(string message, string title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Services/IClipboardService.cs
|
||||||
|
public interface IClipboardService
|
||||||
|
{
|
||||||
|
Task SetTextAsync(string text);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Service Layer Analysis
|
||||||
|
|
||||||
|
### 3.1 Interface Design
|
||||||
|
|
||||||
|
**Location:** `Services/ISecureStoreManager.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface ISecureStoreManager
|
||||||
|
{
|
||||||
|
bool IsStoreOpen { get; }
|
||||||
|
bool HasUnsavedChanges { get; }
|
||||||
|
string? CurrentStorePath { get; }
|
||||||
|
|
||||||
|
void CreateStore(string storePath, string keyFilePath);
|
||||||
|
void CreateStoreWithPassword(string storePath, string password);
|
||||||
|
void OpenStore(string storePath, string keyFilePath);
|
||||||
|
void OpenStoreWithPassword(string storePath, string password);
|
||||||
|
void CloseStore();
|
||||||
|
void Save();
|
||||||
|
|
||||||
|
IReadOnlyList<string> GetKeys();
|
||||||
|
string GetSecret(string key);
|
||||||
|
void SetSecret(string key, string value);
|
||||||
|
void RemoveSecret(string key);
|
||||||
|
|
||||||
|
void GenerateKeyFile(string keyFilePath);
|
||||||
|
void ExportKey(string keyFilePath);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Strengths:**
|
||||||
|
- Clear separation of store lifecycle vs. secret operations
|
||||||
|
- Returns `IReadOnlyList<string>` for keys (immutable)
|
||||||
|
- Synchronous operations appropriate for local file I/O
|
||||||
|
|
||||||
|
### 3.2 Critical Issue: Reserved Key Vulnerability
|
||||||
|
|
||||||
|
**Location:** `Services/SecureStoreManager.cs` (Lines 159-172)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string KeysMetadataKey = "__keys__";
|
||||||
|
|
||||||
|
private void SaveKeysMetadata()
|
||||||
|
{
|
||||||
|
var keysJson = JsonSerializer.Serialize(_keys.ToList());
|
||||||
|
_store!.Set(KeysMetadataKey, keysJson);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vulnerability:** There is no protection against a user calling:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
storeManager.SetSecret("__keys__", "malicious data");
|
||||||
|
```
|
||||||
|
|
||||||
|
This would corrupt the key tracking metadata and potentially break the store.
|
||||||
|
|
||||||
|
**Recommendation:** Add reserved key validation:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static readonly HashSet<string> ReservedKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"__keys__"
|
||||||
|
};
|
||||||
|
|
||||||
|
public void SetSecret(string key, string value)
|
||||||
|
{
|
||||||
|
ThrowIfStoreNotOpen();
|
||||||
|
|
||||||
|
if (ReservedKeys.Contains(key))
|
||||||
|
throw new ArgumentException($"The key '{key}' is reserved for internal use.", nameof(key));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
throw new ArgumentException("Key cannot be null or whitespace.", nameof(key));
|
||||||
|
|
||||||
|
_store!.Set(key, value);
|
||||||
|
_keys.Add(key);
|
||||||
|
HasUnsavedChanges = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Documentation Accuracy
|
||||||
|
|
||||||
|
**Location:** `Services/SecureStoreManager.cs` (Line 8)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Manages secure storage of secrets using SecureStore library.
|
||||||
|
/// Provides a WPF-friendly wrapper around the SecureStore functionality.
|
||||||
|
/// </summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** The comment references WPF but this is an Avalonia application.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Manages secure storage of secrets using SecureStore library.
|
||||||
|
/// Provides an Avalonia-friendly wrapper around the SecureStore functionality.
|
||||||
|
/// </summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ViewModel Analysis
|
||||||
|
|
||||||
|
### 4.1 MainWindowViewModel - God Object Concern
|
||||||
|
|
||||||
|
**Location:** `ViewModels/MainWindowViewModel.cs`
|
||||||
|
|
||||||
|
The ViewModel has approximately 400 lines with:
|
||||||
|
- 16 commands (File, Secret, Tools operations)
|
||||||
|
- State management (store, secrets, selection)
|
||||||
|
- Dialog coordination (7 event delegates)
|
||||||
|
- Error handling
|
||||||
|
- Status message management
|
||||||
|
|
||||||
|
**Recommendation:** Extract use-case classes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Application/UseCases/OpenStoreUseCase.cs
|
||||||
|
public class OpenStoreUseCase
|
||||||
|
{
|
||||||
|
private readonly ISecureStoreManager _storeManager;
|
||||||
|
private readonly IDialogService _dialogService;
|
||||||
|
|
||||||
|
public async Task<bool> ExecuteAsync(string storePath, string? keyFilePath, string? password)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(keyFilePath))
|
||||||
|
_storeManager.OpenStore(storePath, keyFilePath);
|
||||||
|
else if (!string.IsNullOrEmpty(password))
|
||||||
|
_storeManager.OpenStoreWithPassword(storePath, password);
|
||||||
|
else
|
||||||
|
throw new ArgumentException("Either key file or password required.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _dialogService.ShowErrorAsync($"Failed to open store:\n{ex.Message}", "Error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Async Void Anti-Pattern
|
||||||
|
|
||||||
|
**Locations:** Multiple methods in `MainWindowViewModel.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Line 219
|
||||||
|
private async void ExecuteNewStore()
|
||||||
|
{
|
||||||
|
if (!await PromptForUnsavedChangesAsync())
|
||||||
|
return;
|
||||||
|
OnRequestNewStoreDialog?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line 237
|
||||||
|
private async void ExecuteSave()
|
||||||
|
{
|
||||||
|
await ExecuteSaveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line 293
|
||||||
|
private async void ExecuteDeleteSecret()
|
||||||
|
{
|
||||||
|
// ... async operations
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** `async void` methods:
|
||||||
|
- Cannot be awaited
|
||||||
|
- Exceptions propagate to synchronization context (unobservable)
|
||||||
|
- Cannot be unit tested with await patterns
|
||||||
|
|
||||||
|
**Recommendation:** Implement AsyncRelayCommand:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class AsyncRelayCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Func<Task> _execute;
|
||||||
|
private readonly Func<bool>? _canExecute;
|
||||||
|
private bool _isExecuting;
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged;
|
||||||
|
|
||||||
|
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
|
||||||
|
{
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseCanExecuteChanged()
|
||||||
|
{
|
||||||
|
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 CanExecute Not Refreshed on Selection Change
|
||||||
|
|
||||||
|
**Location:** `ViewModels/MainWindowViewModel.cs` (Lines 50-54, 320, 379-392)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public SecretItemViewModel? SelectedSecret
|
||||||
|
{
|
||||||
|
get => _selectedSecret;
|
||||||
|
set => SetProperty(ref _selectedSecret, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanEditOrDeleteSecret() => _storeManager.IsStoreOpen && SelectedSecret != null;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** When `SelectedSecret` changes, the Edit and Delete command states don't update because `RaiseCanExecuteChanged()` is only called in `NotifyStoreChanged()`.
|
||||||
|
|
||||||
|
**Fix:** Add property change notification:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public SecretItemViewModel? SelectedSecret
|
||||||
|
{
|
||||||
|
get => _selectedSecret;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedSecret, value))
|
||||||
|
{
|
||||||
|
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||||
|
(DeleteSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 SecretItemViewModel - Silent Exception Swallowing
|
||||||
|
|
||||||
|
**Location:** `ViewModels/SecretItemViewModel.cs` (Lines 73-86)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async void CopyToClipboard()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OnCopyToClipboard != null)
|
||||||
|
{
|
||||||
|
await OnCopyToClipboard(_actualValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Clipboard operations can fail in some scenarios
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** All exceptions are silently swallowed with no user feedback or logging.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async void CopyToClipboard()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OnCopyToClipboard != null)
|
||||||
|
{
|
||||||
|
await OnCopyToClipboard(_actualValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnCopyFailed?.Invoke($"Failed to copy to clipboard: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action<string>? OnCopyFailed;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dependency Injection Issues
|
||||||
|
|
||||||
|
### 5.1 Hard-Coded Service Creation
|
||||||
|
|
||||||
|
**Location:** `ViewModels/MainWindowViewModel.cs` (Lines 16-17)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public MainWindowViewModel() : this(new Services.SecureStoreManager())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** The parameterless constructor creates a concrete `SecureStoreManager`, violating:
|
||||||
|
- Dependency Inversion Principle
|
||||||
|
- Single Responsibility Principle (ViewModel creates its dependencies)
|
||||||
|
|
||||||
|
**Impact on Testing:** Unit tests must use the constructor with the interface parameter, but the default XAML instantiation uses the parameterless constructor.
|
||||||
|
|
||||||
|
**Recommendation:** Use a service locator or DI container in `App.axaml.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public static IServiceProvider Services { get; private set; } = null!;
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
ConfigureServices(services);
|
||||||
|
Services = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = Services.GetRequiredService<MainWindowViewModel>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<ISecureStoreManager, SecureStoreManager>();
|
||||||
|
services.AddTransient<MainWindowViewModel>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Dialog ViewModel DataContext in XAML
|
||||||
|
|
||||||
|
**Location:** `Views/NewStoreDialog.axaml` (Lines 9-11)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window.DataContext>
|
||||||
|
<vm:NewStoreDialogViewModel />
|
||||||
|
</Window.DataContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** ViewModel is instantiated in XAML, making dependency injection difficult.
|
||||||
|
|
||||||
|
**Recommendation:** Set DataContext in code-behind or use ViewModelLocator pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dialog Validation Redundancy
|
||||||
|
|
||||||
|
### 6.1 Dual Validation
|
||||||
|
|
||||||
|
**Locations:**
|
||||||
|
- `ViewModels/DialogViewModels.cs` - ViewModel validation
|
||||||
|
- `Views/NewStoreDialog.axaml.cs` - Code-behind validation
|
||||||
|
|
||||||
|
ViewModel:
|
||||||
|
```csharp
|
||||||
|
public bool IsValid
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(StorePath))
|
||||||
|
return false;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Code-behind:
|
||||||
|
```csharp
|
||||||
|
private async void CreateButton_Click(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!ViewModel.IsValid)
|
||||||
|
{
|
||||||
|
var box = MessageBoxManager.GetMessageBoxStandard(
|
||||||
|
"Validation Error",
|
||||||
|
ViewModel.ValidationError ?? "Please fill in all required fields.",
|
||||||
|
// ...
|
||||||
|
);
|
||||||
|
await box.ShowWindowDialogAsync(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assessment:** The current approach is functional but:
|
||||||
|
- Validation logic is duplicated conceptually
|
||||||
|
- UI feedback (message box) is in code-behind rather than bound to ViewModel
|
||||||
|
|
||||||
|
**Recommendation:** Bind Create button's `IsEnabled` to `IsValid`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button Content="Create"
|
||||||
|
Click="CreateButton_Click"
|
||||||
|
IsEnabled="{Binding IsValid}"
|
||||||
|
MinWidth="80" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Error Handling Patterns
|
||||||
|
|
||||||
|
### 7.1 Missing JSON Exception Handling
|
||||||
|
|
||||||
|
**Location:** `Services/SecureStoreManager.cs` (Lines 159-172)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void LoadKeysMetadata()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_store!.TryGet(KeysMetadataKey, out string? keysJson))
|
||||||
|
{
|
||||||
|
var keys = JsonSerializer.Deserialize<List<string>>(keysJson);
|
||||||
|
if (keys != null)
|
||||||
|
{
|
||||||
|
_keys = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
_keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assessment:** Good - JSON exceptions are caught and handled gracefully. However, this should be logged for troubleshooting.
|
||||||
|
|
||||||
|
### 7.2 Inconsistent Exception Handling in ViewModel
|
||||||
|
|
||||||
|
**Location:** `ViewModels/MainWindowViewModel.cs`
|
||||||
|
|
||||||
|
Some methods show errors to users:
|
||||||
|
```csharp
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error creating store: {ex.Message}";
|
||||||
|
await (OnShowError?.Invoke($"Failed to create store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Others update status only:
|
||||||
|
```csharp
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error deleting secret: {ex.Message}";
|
||||||
|
// No dialog shown
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation:** Establish consistent error handling policy - critical operations should always show dialog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Code Quality Observations
|
||||||
|
|
||||||
|
### 8.1 Positive Patterns
|
||||||
|
|
||||||
|
- **Null Safety:** Proper use of nullable reference types
|
||||||
|
- **Naming Conventions:** Consistent PascalCase for properties, _camelCase for fields
|
||||||
|
- **LINQ Usage:** Appropriate use of LINQ for collections
|
||||||
|
- **Documentation:** XML comments on public members (though some are outdated)
|
||||||
|
|
||||||
|
### 8.2 Areas for Improvement
|
||||||
|
|
||||||
|
| Area | Current | Recommended |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| Magic Strings | `"__keys__"` | `const string KeysMetadataKey` (already done) |
|
||||||
|
| Constants | Inline `"*.json"`, `"*.key"` | Extract to constants class |
|
||||||
|
| Status Messages | Inline strings | Resource file for localization readiness |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Testing Considerations
|
||||||
|
|
||||||
|
### 9.1 Current Test Coverage
|
||||||
|
|
||||||
|
The project has good test coverage with 140 tests including:
|
||||||
|
- Unit tests for ViewModels
|
||||||
|
- Integration tests for SecureStoreManager
|
||||||
|
- Avalonia headless UI tests
|
||||||
|
|
||||||
|
### 9.2 Testability Improvements
|
||||||
|
|
||||||
|
| Issue | Impact | Solution |
|
||||||
|
|-------|--------|----------|
|
||||||
|
| Hard-coded `SecureStoreManager` | Can't mock in UI tests | Constructor injection via DI |
|
||||||
|
| `async void` commands | Untestable async flow | `AsyncRelayCommand` pattern |
|
||||||
|
| Direct file dialog access | Can't test dialog workflows | `IDialogService` abstraction |
|
||||||
|
| Clipboard in code-behind | Can't test copy functionality | `IClipboardService` abstraction |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Recommendations Summary
|
||||||
|
|
||||||
|
### Priority 1 - Critical
|
||||||
|
|
||||||
|
1. ✅ **[FIXED]** Add reserved key protection in `SetSecret()` to prevent `__keys__` corruption
|
||||||
|
- Added `ReservedKeys` HashSet and validation in `SetSecret()` and `RemoveSecret()`
|
||||||
|
2. ✅ **[FIXED]** Implement dependency injection in `App.axaml.cs` using `IServiceCollection`
|
||||||
|
- Added DI container with logging, services, use-cases, and ViewModels
|
||||||
|
|
||||||
|
### Priority 2 - High
|
||||||
|
|
||||||
|
3. ✅ **[FIXED]** Replace `async void` with `AsyncRelayCommand` pattern
|
||||||
|
- Created `AsyncRelayCommand.cs`, converted all async commands
|
||||||
|
4. ✅ **[FIXED]** Fix CanExecute refresh when `SelectedSecret` changes
|
||||||
|
- Added `RaiseCanExecuteChanged()` calls in `SelectedSecret` setter
|
||||||
|
5. ✅ **[FIXED]** Abstract platform services (`IDialogService`, `IClipboardService`)
|
||||||
|
- Created `Services/IDialogService.cs` and `Services/IClipboardService.cs` interfaces
|
||||||
|
- Created `Services/AvaloniaDialogService.cs` and `Services/AvaloniaClipboardService.cs` implementations
|
||||||
|
- Refactored `MainWindowViewModel` to use `IDialogService` (removed 5 event delegates)
|
||||||
|
- Refactored `SecretItemViewModel` to use `IClipboardService` (removed OnCopyToClipboard event)
|
||||||
|
- ViewModels are now fully testable with mock services
|
||||||
|
|
||||||
|
### Priority 3 - Medium
|
||||||
|
|
||||||
|
6. ✅ **[FIXED]** Extract use-case classes from `MainWindowViewModel`
|
||||||
|
- Created `Application/StoreUseCases.cs` and `Application/SecretUseCases.cs`
|
||||||
|
7. ✅ **[FIXED]** Remove or use `SecretEntry.cs`
|
||||||
|
- Deleted unused `Models/SecretEntry.cs`
|
||||||
|
8. ✅ **[FIXED]** Update documentation (WPF → Avalonia reference)
|
||||||
|
- Changed XML comment in `SecureStoreManager.cs`
|
||||||
|
9. ✅ **[FIXED]** Improve error handling consistency in ViewModels
|
||||||
|
- Added `OnCopyFailed` event in `SecretItemViewModel`
|
||||||
|
|
||||||
|
### Priority 4 - Low
|
||||||
|
|
||||||
|
10. ✅ **[FIXED]** Bind button IsEnabled to validation instead of code-behind checks
|
||||||
|
- Added `IsEnabled="{Binding IsValid}"` to dialog buttons
|
||||||
|
11. ✅ **[FIXED]** Extract constants for file extensions and dialog strings
|
||||||
|
- Created `Constants/DialogStrings.cs` with dialog titles, messages, and validation errors
|
||||||
|
- Created `Constants/FileExtensions.cs` with file patterns and type names
|
||||||
|
- Updated `DialogViewModels.cs`, `MainWindowViewModel.cs`, and dialog Views to use constants
|
||||||
|
12. ✅ **[FIXED]** Add logging for exception handling in services
|
||||||
|
- Added `ILogger<SecureStoreManager>` with structured logging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Files Reviewed
|
||||||
|
|
||||||
|
| File | Lines | Purpose |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `Program.cs` | ~20 | Application entry point |
|
||||||
|
| `App.axaml.cs` | ~25 | Application bootstrapping |
|
||||||
|
| `Models/SecretEntry.cs` | ~10 | Unused domain model |
|
||||||
|
| `Services/ISecureStoreManager.cs` | ~45 | Service interface |
|
||||||
|
| `Services/SecureStoreManager.cs` | ~200 | SecureStore wrapper |
|
||||||
|
| `ViewModels/ViewModelBase.cs` | ~40 | INPC base class |
|
||||||
|
| `ViewModels/RelayCommand.cs` | ~75 | ICommand implementation |
|
||||||
|
| `ViewModels/MainWindowViewModel.cs` | ~420 | Main orchestration VM |
|
||||||
|
| `ViewModels/DialogViewModels.cs` | ~340 | Dialog VMs |
|
||||||
|
| `ViewModels/SecretItemViewModel.cs` | ~90 | Secret item VM |
|
||||||
|
| `Views/MainWindow.axaml` | ~120 | Main window UI |
|
||||||
|
| `Views/MainWindow.axaml.cs` | ~190 | Main window code-behind |
|
||||||
|
| `Views/NewStoreDialog.axaml` | ~100 | New store dialog UI |
|
||||||
|
| `Views/NewStoreDialog.axaml.cs` | ~65 | New store code-behind |
|
||||||
|
| `Views/OpenStoreDialog.axaml.cs` | ~65 | Open store code-behind |
|
||||||
|
| `Views/SecretEditDialog.axaml.cs` | ~45 | Edit dialog code-behind |
|
||||||
|
| `Converters/BooleanConverters.cs` | ~25 | Value converters |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Resolution Details
|
||||||
|
|
||||||
|
**Resolution Date:** January 19, 2026
|
||||||
|
**Resolved By:** Claude Code
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `Services/SecureStoreManager.cs` | Added reserved key protection, ILogger injection, structured logging |
|
||||||
|
| `ViewModels/AsyncRelayCommand.cs` | **NEW** - Async command implementation with CanExecute tracking |
|
||||||
|
| `ViewModels/MainWindowViewModel.cs` | Converted to async commands, fixed SelectedSecret CanExecute refresh |
|
||||||
|
| `ViewModels/SecretItemViewModel.cs` | Added `OnCopyFailed` event for exception surfacing |
|
||||||
|
| `ViewModels/DialogViewModels.cs` | Added `NotifyValidationChanged()` for IsValid binding |
|
||||||
|
| `Application/StoreUseCases.cs` | **NEW** - Store lifecycle use-case class with logging |
|
||||||
|
| `Application/SecretUseCases.cs` | **NEW** - Secret CRUD use-case class with logging |
|
||||||
|
| `App.axaml.cs` | Added DI container with Microsoft.Extensions.DependencyInjection |
|
||||||
|
| `Views/MainWindow.axaml` | Removed XAML DataContext |
|
||||||
|
| `Views/MainWindow.axaml.cs` | Added null checks for headless testing |
|
||||||
|
| `Views/NewStoreDialog.axaml` | Removed XAML DataContext, added IsEnabled binding |
|
||||||
|
| `Views/NewStoreDialog.axaml.cs` | Set DataContext in constructor |
|
||||||
|
| `Views/OpenStoreDialog.axaml` | Removed XAML DataContext, added IsEnabled binding |
|
||||||
|
| `Views/OpenStoreDialog.axaml.cs` | Set DataContext in constructor |
|
||||||
|
| `Views/SecretEditDialog.axaml` | Added IsEnabled binding |
|
||||||
|
| `JdeScoping.SecureStoreManager.csproj` | Added DI and logging NuGet packages |
|
||||||
|
|
||||||
|
### Files Deleted
|
||||||
|
|
||||||
|
| File | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| `Models/SecretEntry.cs` | Unused domain model |
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
|
||||||
|
```
|
||||||
|
Test Run Successful.
|
||||||
|
Total tests: 140
|
||||||
|
Passed: 140
|
||||||
|
Total time: 1.15 Seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deferred Items
|
||||||
|
|
||||||
|
All deferred items have now been addressed:
|
||||||
|
|
||||||
|
| Finding | Status | Resolution |
|
||||||
|
|---------|--------|------------|
|
||||||
|
| Abstract platform services (IDialogService, IClipboardService) | ✅ FIXED | Created interface abstractions and Avalonia implementations; ViewModels now use DI for dialog and clipboard services |
|
||||||
|
| Extract constants for file extensions | ✅ FIXED | Created `Constants/DialogStrings.cs` and `Constants/FileExtensions.cs` with centralized string constants |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Deferred Items Resolution (January 19, 2026)
|
||||||
|
|
||||||
|
### Platform Service Abstractions
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `Services/IDialogService.cs` | **NEW** - Interface for message boxes, confirmation dialogs, and file pickers |
|
||||||
|
| `Services/IClipboardService.cs` | **NEW** - Interface for clipboard operations |
|
||||||
|
| `Services/AvaloniaDialogService.cs` | **NEW** - MsBox.Avalonia + Storage Provider implementation |
|
||||||
|
| `Services/AvaloniaClipboardService.cs` | **NEW** - Avalonia clipboard implementation |
|
||||||
|
| `ViewModels/MainWindowViewModel.cs` | Added IDialogService/IClipboardService injection; removed 5 event delegates |
|
||||||
|
| `ViewModels/SecretItemViewModel.cs` | Added IClipboardService injection; removed OnCopyToClipboard event |
|
||||||
|
| `Views/MainWindow.axaml.cs` | Simplified - removed dialog event subscriptions and handler methods |
|
||||||
|
| `App.axaml.cs` | Registered IDialogService and IClipboardService in DI container |
|
||||||
|
|
||||||
|
### Constants Extraction
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `Constants/DialogStrings.cs` | **NEW** - Dialog titles, messages, validation errors, format strings |
|
||||||
|
| `Constants/FileExtensions.cs` | **NEW** - File patterns (*.json, *.key), extensions, type names |
|
||||||
|
| `ViewModels/DialogViewModels.cs` | Updated validation errors to use `DialogStrings` constants |
|
||||||
|
| `Views/NewStoreDialog.axaml.cs` | Updated to use `DialogStrings.ValidationErrorTitle` |
|
||||||
|
| `Views/OpenStoreDialog.axaml.cs` | Updated to use `DialogStrings.ValidationErrorTitle` |
|
||||||
|
|
||||||
|
### Benefits Achieved
|
||||||
|
|
||||||
|
| Improvement | Before | After |
|
||||||
|
|-------------|--------|-------|
|
||||||
|
| **Testability** | ViewModels untestable for dialog logic | Mock `IDialogService`/`IClipboardService` in unit tests |
|
||||||
|
| **Maintainability** | Strings scattered across 5+ files | Single source of truth in `Constants/` |
|
||||||
|
| **Flexibility** | Tightly coupled to MsBox.Avalonia | Can swap dialog implementations |
|
||||||
|
| **Code reduction** | 15+ event delegates + handlers | 2 clean service interfaces |
|
||||||
|
|
||||||
|
### Test Results After Deferred Item Resolution
|
||||||
|
|
||||||
|
```
|
||||||
|
Test Run Successful.
|
||||||
|
Total tests: 140
|
||||||
|
Passed: 140
|
||||||
|
Total time: 1.11 Seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Review generated with assistance from Claude Code and Codex MCP analysis.*
|
||||||
|
*Resolution implemented by Claude Code on January 19, 2026.*
|
||||||
|
*Deferred items resolved by Claude Code on January 19, 2026.*
|
||||||
@@ -84,7 +84,7 @@ public class PipelineController : ApiControllerBase
|
|||||||
var successKey = $"{tableName}_{(int)updateType}";
|
var successKey = $"{tableName}_{(int)updateType}";
|
||||||
lastSuccessful.TryGetValue(successKey, out var lastSuccess);
|
lastSuccessful.TryGetValue(successKey, out var lastSuccess);
|
||||||
|
|
||||||
var nextRequired = lastSuccess?.EndDt.AddMinutes(interval);
|
var nextRequired = lastSuccess?.EndDt?.AddMinutes(interval);
|
||||||
var isOverdue = DataUpdateRepository.IsOverdue(
|
var isOverdue = DataUpdateRepository.IsOverdue(
|
||||||
lastSuccess?.EndDt, tableName, updateType, null);
|
lastSuccess?.EndDt, tableName, updateType, null);
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ public class DataUpdate
|
|||||||
public DateTime StartDt { get; set; }
|
public DateTime StartDt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Timestamp at end of update
|
/// Timestamp at end of update (null while sync is in progress)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime EndDt { get; set; }
|
public DateTime? EndDt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Type of data update (Hourly, Daily, Mass)
|
/// Type of data update (Hourly, Daily, Mass)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ public class ScheduleChecker : IScheduleChecker
|
|||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Mass sync needed for {Table}: last={LastSync}, interval={Interval}m",
|
"Mass sync needed for {Table}: last={LastSync}, interval={Interval}m",
|
||||||
config.TableName,
|
config.TableName,
|
||||||
lastMass?.EndDt.ToString("o") ?? "never",
|
lastMass?.EndDt?.ToString("o") ?? "never",
|
||||||
config.MassConfig.IntervalMinutes);
|
config.MassConfig.IntervalMinutes);
|
||||||
|
|
||||||
return CreateTask(config, UpdateTypes.Mass, null);
|
return CreateTask(config, UpdateTypes.Mass, null);
|
||||||
@@ -100,7 +100,7 @@ public class ScheduleChecker : IScheduleChecker
|
|||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Daily sync needed for {Table}: last={LastSync}, interval={Interval}m, minDT={MinDT}",
|
"Daily sync needed for {Table}: last={LastSync}, interval={Interval}m, minDT={MinDT}",
|
||||||
config.TableName,
|
config.TableName,
|
||||||
lastDaily?.EndDt.ToString("o") ?? "never",
|
lastDaily?.EndDt?.ToString("o") ?? "never",
|
||||||
config.DailyConfig.IntervalMinutes,
|
config.DailyConfig.IntervalMinutes,
|
||||||
minimumDt?.ToString("o") ?? "null");
|
minimumDt?.ToString("o") ?? "null");
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ public class ScheduleChecker : IScheduleChecker
|
|||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Hourly sync needed for {Table}: last={LastSync}, interval={Interval}m, minDT={MinDT}",
|
"Hourly sync needed for {Table}: last={LastSync}, interval={Interval}m, minDT={MinDT}",
|
||||||
config.TableName,
|
config.TableName,
|
||||||
lastHourly?.EndDt.ToString("o") ?? "never",
|
lastHourly?.EndDt?.ToString("o") ?? "never",
|
||||||
config.HourlyConfig.IntervalMinutes,
|
config.HourlyConfig.IntervalMinutes,
|
||||||
minimumDt?.ToString("o") ?? "null");
|
minimumDt?.ToString("o") ?? "null");
|
||||||
|
|
||||||
@@ -143,7 +143,8 @@ public class ScheduleChecker : IScheduleChecker
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextSyncDue = lastMass.EndDt.AddMinutes(config.MassConfig.IntervalMinutes);
|
// EndDt is set for successful syncs (GetLastDataUpdatesAsync filters WasSuccessful=1)
|
||||||
|
var nextSyncDue = lastMass.EndDt!.Value.AddMinutes(config.MassConfig.IntervalMinutes);
|
||||||
return now > nextSyncDue;
|
return now > nextSyncDue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +171,8 @@ public class ScheduleChecker : IScheduleChecker
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextSyncDue = lastDaily.EndDt.AddMinutes(config.DailyConfig.IntervalMinutes);
|
// EndDt is set for successful syncs (GetLastDataUpdatesAsync filters WasSuccessful=1)
|
||||||
|
var nextSyncDue = lastDaily.EndDt!.Value.AddMinutes(config.DailyConfig.IntervalMinutes);
|
||||||
return now > nextSyncDue;
|
return now > nextSyncDue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +204,8 @@ public class ScheduleChecker : IScheduleChecker
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextSyncDue = lastHourly.EndDt.AddMinutes(config.HourlyConfig.IntervalMinutes);
|
// EndDt is set for successful syncs (GetLastDataUpdatesAsync filters WasSuccessful=1)
|
||||||
|
var nextSyncDue = lastHourly.EndDt!.Value.AddMinutes(config.HourlyConfig.IntervalMinutes);
|
||||||
return now > nextSyncDue;
|
return now > nextSyncDue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +220,8 @@ public class ScheduleChecker : IScheduleChecker
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lookbackMinutes = _options.Value.LookbackMultiplier * intervalMinutes;
|
var lookbackMinutes = _options.Value.LookbackMultiplier * intervalMinutes;
|
||||||
return lastUpdate.EndDt.AddMinutes(-lookbackMinutes);
|
// EndDt is set for successful syncs (GetLastDataUpdatesAsync filters WasSuccessful=1)
|
||||||
|
return lastUpdate.EndDt!.Value.AddMinutes(-lookbackMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ BEGIN
|
|||||||
[SourceData] VARCHAR(50) NOT NULL,
|
[SourceData] VARCHAR(50) NOT NULL,
|
||||||
[TableName] VARCHAR(50) NOT NULL,
|
[TableName] VARCHAR(50) NOT NULL,
|
||||||
[StartDT] DATETIME2(7) NOT NULL,
|
[StartDT] DATETIME2(7) NOT NULL,
|
||||||
[EndDT] DATETIME2(7) NOT NULL,
|
[EndDT] DATETIME2(7) NULL,
|
||||||
[UpdateType] SMALLINT NOT NULL,
|
[UpdateType] SMALLINT NOT NULL,
|
||||||
[WasSuccessful] BIT NOT NULL,
|
[WasSuccessful] BIT NOT NULL,
|
||||||
[NumberRecords] BIGINT NOT NULL,
|
[NumberRecords] BIGINT NOT NULL,
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using JdeScoping.SecureStoreManager.Application;
|
||||||
|
using JdeScoping.SecureStoreManager.Services;
|
||||||
|
using JdeScoping.SecureStoreManager.ViewModels;
|
||||||
using JdeScoping.SecureStoreManager.Views;
|
using JdeScoping.SecureStoreManager.Views;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace JdeScoping.SecureStoreManager;
|
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()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
@@ -14,11 +22,48 @@ public partial class App : Application
|
|||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
ConfigureServices(services);
|
||||||
|
Services = services.BuildServiceProvider();
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
desktop.MainWindow = new MainWindow();
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = Services.GetRequiredService<MainWindowViewModel>()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
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="Avalonia.Controls.DataGrid" Version="11.2.*" />
|
||||||
<PackageReference Include="MessageBox.Avalonia" Version="3.1.*" />
|
<PackageReference Include="MessageBox.Avalonia" Version="3.1.*" />
|
||||||
<PackageReference Include="SecureStore" Version="1.2.0" />
|
<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>
|
</ItemGroup>
|
||||||
</Project>
|
</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.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NeoSmart.SecureStore;
|
using NeoSmart.SecureStore;
|
||||||
|
|
||||||
namespace JdeScoping.SecureStoreManager.Services;
|
namespace JdeScoping.SecureStoreManager.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manages SecureStore encrypted secret stores for the WPF application.
|
/// Manages SecureStore encrypted secret stores for the Avalonia application.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SecureStoreManager : ISecureStoreManager, IDisposable
|
public class SecureStoreManager : ISecureStoreManager, IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<SecureStoreManager> _logger;
|
||||||
private SecretsManager? _secretsManager;
|
private SecretsManager? _secretsManager;
|
||||||
private string? _currentStorePath;
|
private string? _currentStorePath;
|
||||||
private readonly HashSet<string> _keys = new();
|
private readonly HashSet<string> _keys = new();
|
||||||
@@ -17,6 +20,26 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
|
|
||||||
private const string KeysMetadataKey = "__keys__";
|
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 />
|
/// <inheritdoc />
|
||||||
public bool IsStoreOpen => _secretsManager != null;
|
public bool IsStoreOpen => _secretsManager != null;
|
||||||
|
|
||||||
@@ -30,6 +53,7 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
public void CreateStore(string storePath, string keyFilePath)
|
public void CreateStore(string storePath, string keyFilePath)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
_logger.LogInformation("Creating new store at {StorePath}", storePath);
|
||||||
CloseStoreInternal();
|
CloseStoreInternal();
|
||||||
|
|
||||||
EnsureDirectory(storePath);
|
EnsureDirectory(storePath);
|
||||||
@@ -44,12 +68,14 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
_hasUnsavedChanges = true;
|
_hasUnsavedChanges = true;
|
||||||
|
|
||||||
Save();
|
Save();
|
||||||
|
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void CreateStoreWithPassword(string storePath, string password)
|
public void CreateStoreWithPassword(string storePath, string password)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
_logger.LogInformation("Creating password-protected store at {StorePath}", storePath);
|
||||||
CloseStoreInternal();
|
CloseStoreInternal();
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(password))
|
||||||
@@ -65,12 +91,14 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
_hasUnsavedChanges = true;
|
_hasUnsavedChanges = true;
|
||||||
|
|
||||||
Save();
|
Save();
|
||||||
|
_logger.LogInformation("Password-protected store created");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void OpenStore(string storePath, string keyFilePath)
|
public void OpenStore(string storePath, string keyFilePath)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
_logger.LogInformation("Opening store at {StorePath}", storePath);
|
||||||
CloseStoreInternal();
|
CloseStoreInternal();
|
||||||
|
|
||||||
if (!File.Exists(storePath))
|
if (!File.Exists(storePath))
|
||||||
@@ -85,12 +113,14 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
_currentStorePath = storePath;
|
_currentStorePath = storePath;
|
||||||
LoadKeysMetadata();
|
LoadKeysMetadata();
|
||||||
_hasUnsavedChanges = false;
|
_hasUnsavedChanges = false;
|
||||||
|
_logger.LogDebug("Store opened with key file, contains {KeyCount} keys", _keys.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void OpenStoreWithPassword(string storePath, string password)
|
public void OpenStoreWithPassword(string storePath, string password)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
_logger.LogInformation("Opening store at {StorePath} with password", storePath);
|
||||||
CloseStoreInternal();
|
CloseStoreInternal();
|
||||||
|
|
||||||
if (!File.Exists(storePath))
|
if (!File.Exists(storePath))
|
||||||
@@ -105,12 +135,14 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
_currentStorePath = storePath;
|
_currentStorePath = storePath;
|
||||||
LoadKeysMetadata();
|
LoadKeysMetadata();
|
||||||
_hasUnsavedChanges = false;
|
_hasUnsavedChanges = false;
|
||||||
|
_logger.LogDebug("Store opened with password, contains {KeyCount} keys", _keys.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void CloseStore()
|
public void CloseStore()
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
_logger.LogInformation("Closing store");
|
||||||
CloseStoreInternal();
|
CloseStoreInternal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +154,7 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
if (_secretsManager == null || _currentStorePath == null)
|
if (_secretsManager == null || _currentStorePath == null)
|
||||||
throw new InvalidOperationException("No store is currently open.");
|
throw new InvalidOperationException("No store is currently open.");
|
||||||
|
|
||||||
|
_logger.LogInformation("Saving store changes");
|
||||||
SaveKeysMetadata();
|
SaveKeysMetadata();
|
||||||
_secretsManager.SaveStore(_currentStorePath);
|
_secretsManager.SaveStore(_currentStorePath);
|
||||||
_hasUnsavedChanges = false;
|
_hasUnsavedChanges = false;
|
||||||
@@ -163,9 +196,16 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
if (_secretsManager == null)
|
if (_secretsManager == null)
|
||||||
throw new InvalidOperationException("No store is currently open.");
|
throw new InvalidOperationException("No store is currently open.");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(key))
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
throw new ArgumentException("Key cannot be empty.", nameof(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);
|
_secretsManager.Set(key, value ?? string.Empty);
|
||||||
_keys.Add(key);
|
_keys.Add(key);
|
||||||
_hasUnsavedChanges = true;
|
_hasUnsavedChanges = true;
|
||||||
@@ -179,12 +219,19 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
|
|||||||
if (_secretsManager == null)
|
if (_secretsManager == null)
|
||||||
throw new InvalidOperationException("No store is currently open.");
|
throw new InvalidOperationException("No store is currently open.");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(key))
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
throw new ArgumentException("Key cannot be empty.", nameof(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))
|
if (!_keys.Remove(key))
|
||||||
throw new KeyNotFoundException($"Secret '{key}' not found.");
|
throw new KeyNotFoundException($"Secret '{key}' not found.");
|
||||||
|
|
||||||
|
_logger.LogInformation("Removing secret for key {Key}", key);
|
||||||
_secretsManager.Delete(key);
|
_secretsManager.Delete(key);
|
||||||
_hasUnsavedChanges = true;
|
_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 System.Windows.Input;
|
||||||
|
using JdeScoping.SecureStoreManager.Constants;
|
||||||
|
|
||||||
namespace JdeScoping.SecureStoreManager.ViewModels;
|
namespace JdeScoping.SecureStoreManager.ViewModels;
|
||||||
|
|
||||||
@@ -23,25 +24,41 @@ public class NewStoreDialogViewModel : ViewModelBase
|
|||||||
public string StorePath
|
public string StorePath
|
||||||
{
|
{
|
||||||
get => _storePath;
|
get => _storePath;
|
||||||
set => SetProperty(ref _storePath, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _storePath, value))
|
||||||
|
NotifyValidationChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string KeyFilePath
|
public string KeyFilePath
|
||||||
{
|
{
|
||||||
get => _keyFilePath;
|
get => _keyFilePath;
|
||||||
set => SetProperty(ref _keyFilePath, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _keyFilePath, value))
|
||||||
|
NotifyValidationChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Password
|
public string Password
|
||||||
{
|
{
|
||||||
get => _password;
|
get => _password;
|
||||||
set => SetProperty(ref _password, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _password, value))
|
||||||
|
NotifyValidationChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ConfirmPassword
|
public string ConfirmPassword
|
||||||
{
|
{
|
||||||
get => _confirmPassword;
|
get => _confirmPassword;
|
||||||
set => SetProperty(ref _confirmPassword, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _confirmPassword, value))
|
||||||
|
NotifyValidationChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool UseKeyFile
|
public bool UseKeyFile
|
||||||
@@ -52,6 +69,7 @@ public class NewStoreDialogViewModel : ViewModelBase
|
|||||||
if (SetProperty(ref _useKeyFile, value))
|
if (SetProperty(ref _useKeyFile, value))
|
||||||
{
|
{
|
||||||
if (value) UsePassword = false;
|
if (value) UsePassword = false;
|
||||||
|
NotifyValidationChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,10 +82,17 @@ public class NewStoreDialogViewModel : ViewModelBase
|
|||||||
if (SetProperty(ref _usePassword, value))
|
if (SetProperty(ref _usePassword, value))
|
||||||
{
|
{
|
||||||
if (value) UseKeyFile = false;
|
if (value) UseKeyFile = false;
|
||||||
|
NotifyValidationChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void NotifyValidationChanged()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsValid));
|
||||||
|
OnPropertyChanged(nameof(ValidationError));
|
||||||
|
}
|
||||||
|
|
||||||
public ICommand BrowseStorePathCommand { get; }
|
public ICommand BrowseStorePathCommand { get; }
|
||||||
public ICommand BrowseKeyFilePathCommand { get; }
|
public ICommand BrowseKeyFilePathCommand { get; }
|
||||||
|
|
||||||
@@ -93,18 +118,18 @@ public class NewStoreDialogViewModel : ViewModelBase
|
|||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(StorePath))
|
if (string.IsNullOrWhiteSpace(StorePath))
|
||||||
return "Store path is required.";
|
return DialogStrings.StorePathRequired;
|
||||||
|
|
||||||
if (UseKeyFile && string.IsNullOrWhiteSpace(KeyFilePath))
|
if (UseKeyFile && string.IsNullOrWhiteSpace(KeyFilePath))
|
||||||
return "Key file path is required.";
|
return DialogStrings.KeyFilePathRequired;
|
||||||
|
|
||||||
if (UsePassword)
|
if (UsePassword)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(Password))
|
if (string.IsNullOrWhiteSpace(Password))
|
||||||
return "Password is required.";
|
return DialogStrings.PasswordRequired;
|
||||||
|
|
||||||
if (Password != ConfirmPassword)
|
if (Password != ConfirmPassword)
|
||||||
return "Passwords do not match.";
|
return DialogStrings.PasswordsDoNotMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -123,7 +148,11 @@ public class NewStoreDialogViewModel : ViewModelBase
|
|||||||
if (OnShowSaveFileDialog == null)
|
if (OnShowSaveFileDialog == null)
|
||||||
return;
|
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))
|
if (!string.IsNullOrEmpty(path))
|
||||||
{
|
{
|
||||||
StorePath = path;
|
StorePath = path;
|
||||||
@@ -135,7 +164,11 @@ public class NewStoreDialogViewModel : ViewModelBase
|
|||||||
if (OnShowSaveFileDialog == null)
|
if (OnShowSaveFileDialog == null)
|
||||||
return;
|
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))
|
if (!string.IsNullOrEmpty(path))
|
||||||
{
|
{
|
||||||
KeyFilePath = path;
|
KeyFilePath = path;
|
||||||
@@ -163,19 +196,31 @@ public class OpenStoreDialogViewModel : ViewModelBase
|
|||||||
public string StorePath
|
public string StorePath
|
||||||
{
|
{
|
||||||
get => _storePath;
|
get => _storePath;
|
||||||
set => SetProperty(ref _storePath, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _storePath, value))
|
||||||
|
NotifyValidationChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string KeyFilePath
|
public string KeyFilePath
|
||||||
{
|
{
|
||||||
get => _keyFilePath;
|
get => _keyFilePath;
|
||||||
set => SetProperty(ref _keyFilePath, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _keyFilePath, value))
|
||||||
|
NotifyValidationChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Password
|
public string Password
|
||||||
{
|
{
|
||||||
get => _password;
|
get => _password;
|
||||||
set => SetProperty(ref _password, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _password, value))
|
||||||
|
NotifyValidationChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool UseKeyFile
|
public bool UseKeyFile
|
||||||
@@ -186,6 +231,7 @@ public class OpenStoreDialogViewModel : ViewModelBase
|
|||||||
if (SetProperty(ref _useKeyFile, value))
|
if (SetProperty(ref _useKeyFile, value))
|
||||||
{
|
{
|
||||||
if (value) UsePassword = false;
|
if (value) UsePassword = false;
|
||||||
|
NotifyValidationChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,10 +244,17 @@ public class OpenStoreDialogViewModel : ViewModelBase
|
|||||||
if (SetProperty(ref _usePassword, value))
|
if (SetProperty(ref _usePassword, value))
|
||||||
{
|
{
|
||||||
if (value) UseKeyFile = false;
|
if (value) UseKeyFile = false;
|
||||||
|
NotifyValidationChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void NotifyValidationChanged()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsValid));
|
||||||
|
OnPropertyChanged(nameof(ValidationError));
|
||||||
|
}
|
||||||
|
|
||||||
public ICommand BrowseStorePathCommand { get; }
|
public ICommand BrowseStorePathCommand { get; }
|
||||||
public ICommand BrowseKeyFilePathCommand { get; }
|
public ICommand BrowseKeyFilePathCommand { get; }
|
||||||
|
|
||||||
@@ -227,22 +280,22 @@ public class OpenStoreDialogViewModel : ViewModelBase
|
|||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(StorePath))
|
if (string.IsNullOrWhiteSpace(StorePath))
|
||||||
return "Store path is required.";
|
return DialogStrings.StorePathRequired;
|
||||||
|
|
||||||
if (!System.IO.File.Exists(StorePath))
|
if (!System.IO.File.Exists(StorePath))
|
||||||
return "Store file does not exist.";
|
return DialogStrings.StoreFileNotFound;
|
||||||
|
|
||||||
if (UseKeyFile)
|
if (UseKeyFile)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(KeyFilePath))
|
if (string.IsNullOrWhiteSpace(KeyFilePath))
|
||||||
return "Key file path is required.";
|
return DialogStrings.KeyFilePathRequired;
|
||||||
|
|
||||||
if (!System.IO.File.Exists(KeyFilePath))
|
if (!System.IO.File.Exists(KeyFilePath))
|
||||||
return "Key file does not exist.";
|
return DialogStrings.KeyFileNotFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (UsePassword && string.IsNullOrWhiteSpace(Password))
|
if (UsePassword && string.IsNullOrWhiteSpace(Password))
|
||||||
return "Password is required.";
|
return DialogStrings.PasswordRequired;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -260,7 +313,10 @@ public class OpenStoreDialogViewModel : ViewModelBase
|
|||||||
if (OnShowOpenFileDialog == null)
|
if (OnShowOpenFileDialog == null)
|
||||||
return;
|
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))
|
if (!string.IsNullOrEmpty(path))
|
||||||
{
|
{
|
||||||
StorePath = path;
|
StorePath = path;
|
||||||
@@ -272,7 +328,10 @@ public class OpenStoreDialogViewModel : ViewModelBase
|
|||||||
if (OnShowOpenFileDialog == null)
|
if (OnShowOpenFileDialog == null)
|
||||||
return;
|
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))
|
if (!string.IsNullOrEmpty(path))
|
||||||
{
|
{
|
||||||
KeyFilePath = path;
|
KeyFilePath = path;
|
||||||
@@ -303,7 +362,11 @@ public class SecretEditDialogViewModel : ViewModelBase
|
|||||||
public string Key
|
public string Key
|
||||||
{
|
{
|
||||||
get => _key;
|
get => _key;
|
||||||
set => SetProperty(ref _key, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _key, value))
|
||||||
|
NotifyValidationChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Value
|
public string Value
|
||||||
@@ -329,9 +392,15 @@ public class SecretEditDialogViewModel : ViewModelBase
|
|||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(Key))
|
if (string.IsNullOrWhiteSpace(Key))
|
||||||
return "Key is required.";
|
return DialogStrings.KeyRequired;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void NotifyValidationChanged()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsValid));
|
||||||
|
OnPropertyChanged(nameof(ValidationError));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
|
using JdeScoping.SecureStoreManager.Constants;
|
||||||
using JdeScoping.SecureStoreManager.Services;
|
using JdeScoping.SecureStoreManager.Services;
|
||||||
|
|
||||||
namespace JdeScoping.SecureStoreManager.ViewModels;
|
namespace JdeScoping.SecureStoreManager.ViewModels;
|
||||||
@@ -10,33 +11,36 @@ namespace JdeScoping.SecureStoreManager.ViewModels;
|
|||||||
public class MainWindowViewModel : ViewModelBase
|
public class MainWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ISecureStoreManager _storeManager;
|
private readonly ISecureStoreManager _storeManager;
|
||||||
|
private readonly IDialogService _dialogService;
|
||||||
|
private readonly IClipboardService _clipboardService;
|
||||||
private SecretItemViewModel? _selectedSecret;
|
private SecretItemViewModel? _selectedSecret;
|
||||||
private string _statusMessage = "Ready";
|
private string _statusMessage = "Ready";
|
||||||
|
|
||||||
public MainWindowViewModel() : this(new Services.SecureStoreManager())
|
public MainWindowViewModel(
|
||||||
|
ISecureStoreManager storeManager,
|
||||||
|
IDialogService dialogService,
|
||||||
|
IClipboardService clipboardService)
|
||||||
{
|
{
|
||||||
}
|
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
|
||||||
|
_dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService));
|
||||||
public MainWindowViewModel(ISecureStoreManager storeManager)
|
_clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService));
|
||||||
{
|
|
||||||
_storeManager = storeManager;
|
|
||||||
Secrets = new ObservableCollection<SecretItemViewModel>();
|
Secrets = new ObservableCollection<SecretItemViewModel>();
|
||||||
|
|
||||||
// File commands
|
// File commands (async)
|
||||||
NewStoreCommand = new RelayCommand(ExecuteNewStore);
|
NewStoreCommand = new AsyncRelayCommand(ExecuteNewStoreAsync);
|
||||||
OpenStoreCommand = new RelayCommand(ExecuteOpenStore);
|
OpenStoreCommand = new AsyncRelayCommand(ExecuteOpenStoreAsync);
|
||||||
SaveCommand = new RelayCommand(ExecuteSave, CanSave);
|
SaveCommand = new AsyncRelayCommand(ExecuteSaveAsync, CanSave);
|
||||||
CloseStoreCommand = new RelayCommand(ExecuteCloseStore, () => _storeManager.IsStoreOpen);
|
CloseStoreCommand = new AsyncRelayCommand(ExecuteCloseStoreAsync, () => _storeManager.IsStoreOpen);
|
||||||
ExitCommand = new RelayCommand(ExecuteExit);
|
ExitCommand = new AsyncRelayCommand(ExecuteExitAsync);
|
||||||
|
|
||||||
// Secret commands
|
// Secret commands
|
||||||
AddSecretCommand = new RelayCommand(ExecuteAddSecret, () => _storeManager.IsStoreOpen);
|
AddSecretCommand = new RelayCommand(ExecuteAddSecret, () => _storeManager.IsStoreOpen);
|
||||||
EditSecretCommand = new RelayCommand(ExecuteEditSecret, CanEditOrDeleteSecret);
|
EditSecretCommand = new RelayCommand(ExecuteEditSecret, CanEditOrDeleteSecret);
|
||||||
DeleteSecretCommand = new RelayCommand(ExecuteDeleteSecret, CanEditOrDeleteSecret);
|
DeleteSecretCommand = new AsyncRelayCommand(ExecuteDeleteSecretAsync, CanEditOrDeleteSecret);
|
||||||
|
|
||||||
// Tools commands
|
// Tools commands (async)
|
||||||
GenerateKeyFileCommand = new RelayCommand(ExecuteGenerateKeyFile);
|
GenerateKeyFileCommand = new AsyncRelayCommand(ExecuteGenerateKeyFileAsync);
|
||||||
ExportKeyCommand = new RelayCommand(ExecuteExportKey, () => _storeManager.IsStoreOpen);
|
ExportKeyCommand = new AsyncRelayCommand(ExecuteExportKeyAsync, () => _storeManager.IsStoreOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,7 +54,14 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
public SecretItemViewModel? SelectedSecret
|
public SecretItemViewModel? SelectedSecret
|
||||||
{
|
{
|
||||||
get => _selectedSecret;
|
get => _selectedSecret;
|
||||||
set => SetProperty(ref _selectedSecret, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedSecret, value))
|
||||||
|
{
|
||||||
|
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||||
|
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -134,7 +145,9 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Error creating store: {ex.Message}";
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Error opening store: {ex.Message}";
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Error saving secret: {ex.Message}";
|
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)
|
if (!_storeManager.HasUnsavedChanges)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
if (OnShowUnsavedChangesPrompt == null)
|
var result = await _dialogService.ShowUnsavedChangesPromptAsync();
|
||||||
return true;
|
|
||||||
|
|
||||||
var result = await OnShowUnsavedChangesPrompt();
|
|
||||||
|
|
||||||
switch (result)
|
switch (result)
|
||||||
{
|
{
|
||||||
@@ -216,7 +230,7 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ExecuteNewStore()
|
private async Task ExecuteNewStoreAsync()
|
||||||
{
|
{
|
||||||
if (!await PromptForUnsavedChangesAsync())
|
if (!await PromptForUnsavedChangesAsync())
|
||||||
return;
|
return;
|
||||||
@@ -225,7 +239,7 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
OnRequestNewStoreDialog?.Invoke();
|
OnRequestNewStoreDialog?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ExecuteOpenStore()
|
private async Task ExecuteOpenStoreAsync()
|
||||||
{
|
{
|
||||||
if (!await PromptForUnsavedChangesAsync())
|
if (!await PromptForUnsavedChangesAsync())
|
||||||
return;
|
return;
|
||||||
@@ -234,11 +248,6 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
OnRequestOpenStoreDialog?.Invoke();
|
OnRequestOpenStoreDialog?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ExecuteSave()
|
|
||||||
{
|
|
||||||
await ExecuteSaveAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExecuteSaveAsync()
|
private async Task ExecuteSaveAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -250,13 +259,15 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Error saving: {ex.Message}";
|
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 bool CanSave() => _storeManager.IsStoreOpen && _storeManager.HasUnsavedChanges;
|
||||||
|
|
||||||
private async void ExecuteCloseStore()
|
private async Task ExecuteCloseStoreAsync()
|
||||||
{
|
{
|
||||||
if (!await PromptForUnsavedChangesAsync())
|
if (!await PromptForUnsavedChangesAsync())
|
||||||
return;
|
return;
|
||||||
@@ -267,7 +278,7 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
StatusMessage = "Store closed";
|
StatusMessage = "Store closed";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ExecuteExit()
|
private async Task ExecuteExitAsync()
|
||||||
{
|
{
|
||||||
if (!await PromptForUnsavedChangesAsync())
|
if (!await PromptForUnsavedChangesAsync())
|
||||||
return;
|
return;
|
||||||
@@ -290,15 +301,13 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
OnRequestEditSecretDialog?.Invoke(SelectedSecret.Key, SelectedSecret.ActualValue);
|
OnRequestEditSecretDialog?.Invoke(SelectedSecret.Key, SelectedSecret.ActualValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ExecuteDeleteSecret()
|
private async Task ExecuteDeleteSecretAsync()
|
||||||
{
|
{
|
||||||
if (SelectedSecret == null)
|
if (SelectedSecret == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (OnShowDeleteConfirmation == null)
|
var confirmMessage = string.Format(DialogStrings.ConfirmDeleteFormat, SelectedSecret.Key);
|
||||||
return;
|
var confirmed = await _dialogService.ShowConfirmationAsync(confirmMessage, DialogStrings.ConfirmDeleteTitle);
|
||||||
|
|
||||||
var confirmed = await OnShowDeleteConfirmation(SelectedSecret.Key);
|
|
||||||
if (!confirmed)
|
if (!confirmed)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -313,18 +322,22 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Error deleting secret: {ex.Message}";
|
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 bool CanEditOrDeleteSecret() => _storeManager.IsStoreOpen && SelectedSecret != null;
|
||||||
|
|
||||||
private async void ExecuteGenerateKeyFile()
|
private async Task ExecuteGenerateKeyFileAsync()
|
||||||
{
|
{
|
||||||
if (OnShowSaveFileDialog == null)
|
var filePath = await _dialogService.ShowSaveFileDialogAsync(
|
||||||
return;
|
DialogStrings.GenerateKeyFileTitle,
|
||||||
|
FileExtensions.KeyTypeName,
|
||||||
|
FileExtensions.KeyPattern,
|
||||||
|
FileExtensions.KeyExtension);
|
||||||
|
|
||||||
var filePath = await OnShowSaveFileDialog("Generate Key File", "Key Files", "*.key", ".key");
|
|
||||||
if (string.IsNullOrEmpty(filePath))
|
if (string.IsNullOrEmpty(filePath))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -332,21 +345,27 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
_storeManager.GenerateKeyFile(filePath);
|
_storeManager.GenerateKeyFile(filePath);
|
||||||
StatusMessage = $"Generated key file: {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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Error generating key: {ex.Message}";
|
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)
|
var filePath = await _dialogService.ShowSaveFileDialogAsync(
|
||||||
return;
|
DialogStrings.ExportKeyTitle,
|
||||||
|
FileExtensions.KeyTypeName,
|
||||||
|
FileExtensions.KeyPattern,
|
||||||
|
FileExtensions.KeyExtension);
|
||||||
|
|
||||||
var filePath = await OnShowSaveFileDialog("Export Key", "Key Files", "*.key", ".key");
|
|
||||||
if (string.IsNullOrEmpty(filePath))
|
if (string.IsNullOrEmpty(filePath))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -354,12 +373,16 @@ public class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
_storeManager.ExportKey(filePath);
|
_storeManager.ExportKey(filePath);
|
||||||
StatusMessage = $"Exported key to: {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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Error exporting key: {ex.Message}";
|
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())
|
foreach (var key in _storeManager.GetKeys())
|
||||||
{
|
{
|
||||||
var value = _storeManager.GetSecret(key);
|
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));
|
OnPropertyChanged(nameof(WindowTitle));
|
||||||
|
|
||||||
// Manually raise CanExecuteChanged for all commands
|
// Manually raise CanExecuteChanged for all commands
|
||||||
(SaveCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
(SaveCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||||
(CloseStoreCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
(CloseStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||||
(AddSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
(AddSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||||
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||||
(DeleteSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||||
(ExportKeyCommand as RelayCommand)?.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? OnRequestNewStoreDialog;
|
||||||
public event Action? OnRequestOpenStoreDialog;
|
public event Action? OnRequestOpenStoreDialog;
|
||||||
public event Action? OnRequestAddSecretDialog;
|
public event Action? OnRequestAddSecretDialog;
|
||||||
public event Action<string, string>? OnRequestEditSecretDialog;
|
public event Action<string, string>? OnRequestEditSecretDialog;
|
||||||
public event Action? OnRequestClose;
|
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 System.Windows.Input;
|
||||||
|
using JdeScoping.SecureStoreManager.Services;
|
||||||
|
|
||||||
namespace JdeScoping.SecureStoreManager.ViewModels;
|
namespace JdeScoping.SecureStoreManager.ViewModels;
|
||||||
|
|
||||||
@@ -8,13 +9,15 @@ namespace JdeScoping.SecureStoreManager.ViewModels;
|
|||||||
public class SecretItemViewModel : ViewModelBase
|
public class SecretItemViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly string _actualValue;
|
private readonly string _actualValue;
|
||||||
|
private readonly IClipboardService _clipboardService;
|
||||||
private bool _isValueVisible;
|
private bool _isValueVisible;
|
||||||
private const string MaskedValue = "********";
|
private const string MaskedValue = "********";
|
||||||
|
|
||||||
public SecretItemViewModel(string key, string value)
|
public SecretItemViewModel(string key, string value, IClipboardService clipboardService)
|
||||||
{
|
{
|
||||||
Key = key;
|
Key = key;
|
||||||
_actualValue = value;
|
_actualValue = value;
|
||||||
|
_clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService));
|
||||||
ToggleVisibilityCommand = new RelayCommand(ToggleVisibility);
|
ToggleVisibilityCommand = new RelayCommand(ToggleVisibility);
|
||||||
CopyToClipboardCommand = new RelayCommand(CopyToClipboard);
|
CopyToClipboardCommand = new RelayCommand(CopyToClipboard);
|
||||||
}
|
}
|
||||||
@@ -60,10 +63,9 @@ public class SecretItemViewModel : ViewModelBase
|
|||||||
public ICommand CopyToClipboardCommand { get; }
|
public ICommand CopyToClipboardCommand { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Event raised when clipboard copy is requested.
|
/// Event raised when clipboard copy fails.
|
||||||
/// The view subscribes to this to perform the actual clipboard operation.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event Func<string, Task>? OnCopyToClipboard;
|
public event Action<string>? OnCopyFailed;
|
||||||
|
|
||||||
private void ToggleVisibility()
|
private void ToggleVisibility()
|
||||||
{
|
{
|
||||||
@@ -74,14 +76,11 @@ public class SecretItemViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (OnCopyToClipboard != null)
|
await _clipboardService.SetTextAsync(_actualValue);
|
||||||
{
|
|
||||||
await OnCopyToClipboard(_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"
|
Height="500" Width="800"
|
||||||
MinHeight="400" MinWidth="600"
|
MinHeight="400" MinWidth="600"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen">
|
||||||
<Window.DataContext>
|
<!-- DataContext is set via DI in App.axaml.cs -->
|
||||||
<vm:MainWindowViewModel />
|
|
||||||
</Window.DataContext>
|
|
||||||
|
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
<KeyBinding Gesture="Ctrl+N" Command="{Binding NewStoreCommand}" />
|
<KeyBinding Gesture="Ctrl+N" Command="{Binding NewStoreCommand}" />
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
|
||||||
using JdeScoping.SecureStoreManager.ViewModels;
|
using JdeScoping.SecureStoreManager.ViewModels;
|
||||||
using MsBox.Avalonia;
|
|
||||||
using MsBox.Avalonia.Enums;
|
|
||||||
|
|
||||||
namespace JdeScoping.SecureStoreManager.Views;
|
namespace JdeScoping.SecureStoreManager.Views;
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
private MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!;
|
private MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
@@ -21,35 +18,22 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void MainWindow_Loaded(object? sender, RoutedEventArgs e)
|
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.OnRequestNewStoreDialog += ShowNewStoreDialog;
|
||||||
ViewModel.OnRequestOpenStoreDialog += ShowOpenStoreDialog;
|
ViewModel.OnRequestOpenStoreDialog += ShowOpenStoreDialog;
|
||||||
ViewModel.OnRequestAddSecretDialog += ShowAddSecretDialog;
|
ViewModel.OnRequestAddSecretDialog += ShowAddSecretDialog;
|
||||||
ViewModel.OnRequestEditSecretDialog += ShowEditSecretDialog;
|
ViewModel.OnRequestEditSecretDialog += ShowEditSecretDialog;
|
||||||
ViewModel.OnRequestClose += () => Close();
|
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)
|
private async void MainWindow_Closing(object? sender, WindowClosingEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (ViewModel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
e.Cancel = true;
|
e.Cancel = true;
|
||||||
if (await ViewModel.PromptForUnsavedChangesAsync())
|
if (await ViewModel.PromptForUnsavedChangesAsync())
|
||||||
{
|
{
|
||||||
@@ -59,7 +43,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void DataGrid_DoubleTapped(object? sender, TappedEventArgs e)
|
private void DataGrid_DoubleTapped(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (ViewModel.SelectedSecret != null)
|
if (ViewModel?.SelectedSecret != null)
|
||||||
{
|
{
|
||||||
ViewModel.EditSecretCommand.Execute(null);
|
ViewModel.EditSecretCommand.Execute(null);
|
||||||
}
|
}
|
||||||
@@ -67,6 +51,8 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private async void ShowNewStoreDialog()
|
private async void ShowNewStoreDialog()
|
||||||
{
|
{
|
||||||
|
if (ViewModel == null) return;
|
||||||
|
|
||||||
var dialog = new NewStoreDialog();
|
var dialog = new NewStoreDialog();
|
||||||
var result = await dialog.ShowDialog<bool?>(this);
|
var result = await dialog.ShowDialog<bool?>(this);
|
||||||
if (result == true)
|
if (result == true)
|
||||||
@@ -81,6 +67,8 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private async void ShowOpenStoreDialog()
|
private async void ShowOpenStoreDialog()
|
||||||
{
|
{
|
||||||
|
if (ViewModel == null) return;
|
||||||
|
|
||||||
var dialog = new OpenStoreDialog();
|
var dialog = new OpenStoreDialog();
|
||||||
var result = await dialog.ShowDialog<bool?>(this);
|
var result = await dialog.ShowDialog<bool?>(this);
|
||||||
if (result == true)
|
if (result == true)
|
||||||
@@ -95,6 +83,8 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private async void ShowAddSecretDialog()
|
private async void ShowAddSecretDialog()
|
||||||
{
|
{
|
||||||
|
if (ViewModel == null) return;
|
||||||
|
|
||||||
var dialog = new SecretEditDialog();
|
var dialog = new SecretEditDialog();
|
||||||
var result = await dialog.ShowDialog<bool?>(this);
|
var result = await dialog.ShowDialog<bool?>(this);
|
||||||
if (result == true)
|
if (result == true)
|
||||||
@@ -106,6 +96,8 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private async void ShowEditSecretDialog(string key, string value)
|
private async void ShowEditSecretDialog(string key, string value)
|
||||||
{
|
{
|
||||||
|
if (ViewModel == null) return;
|
||||||
|
|
||||||
var dialog = new SecretEditDialog(key, value);
|
var dialog = new SecretEditDialog(key, value);
|
||||||
var result = await dialog.ShowDialog<bool?>(this);
|
var result = await dialog.ShowDialog<bool?>(this);
|
||||||
if (result == true)
|
if (result == true)
|
||||||
@@ -114,74 +106,4 @@ public partial class MainWindow : Window
|
|||||||
await ViewModel.SaveSecretAsync(vm.Key, vm.Value, isNew: false);
|
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"
|
WindowStartupLocation="CenterOwner"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
ShowInTaskbar="False">
|
ShowInTaskbar="False">
|
||||||
<Window.DataContext>
|
<!-- DataContext is set in code-behind -->
|
||||||
<vm:NewStoreDialogViewModel />
|
|
||||||
</Window.DataContext>
|
|
||||||
|
|
||||||
<Grid Margin="15" RowDefinitions="Auto,Auto,Auto,*,Auto">
|
<Grid Margin="15" RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||||
<!-- Store Path -->
|
<!-- Store Path -->
|
||||||
@@ -93,7 +91,7 @@
|
|||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
|
<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" />
|
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using JdeScoping.SecureStoreManager.Constants;
|
||||||
using JdeScoping.SecureStoreManager.ViewModels;
|
using JdeScoping.SecureStoreManager.ViewModels;
|
||||||
using MsBox.Avalonia;
|
using MsBox.Avalonia;
|
||||||
using MsBox.Avalonia.Enums;
|
using MsBox.Avalonia.Enums;
|
||||||
@@ -14,6 +15,7 @@ public partial class NewStoreDialog : Window
|
|||||||
public NewStoreDialog()
|
public NewStoreDialog()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
DataContext = new NewStoreDialogViewModel();
|
||||||
Loaded += NewStoreDialog_Loaded;
|
Loaded += NewStoreDialog_Loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ public partial class NewStoreDialog : Window
|
|||||||
FileTypeChoices = new[]
|
FileTypeChoices = new[]
|
||||||
{
|
{
|
||||||
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
|
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
|
var box = MessageBoxManager
|
||||||
.GetMessageBoxStandard(
|
.GetMessageBoxStandard(
|
||||||
"Validation Error",
|
DialogStrings.ValidationErrorTitle,
|
||||||
ViewModel.ValidationError ?? "Please fill in all required fields.",
|
ViewModel.ValidationError ?? DialogStrings.DefaultValidationError,
|
||||||
ButtonEnum.Ok,
|
ButtonEnum.Ok,
|
||||||
MsBox.Avalonia.Enums.Icon.Warning);
|
MsBox.Avalonia.Enums.Icon.Warning);
|
||||||
await box.ShowWindowDialogAsync(this);
|
await box.ShowWindowDialogAsync(this);
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
ShowInTaskbar="False">
|
ShowInTaskbar="False">
|
||||||
<Window.DataContext>
|
<!-- DataContext is set in code-behind -->
|
||||||
<vm:OpenStoreDialogViewModel />
|
|
||||||
</Window.DataContext>
|
|
||||||
|
|
||||||
<Grid Margin="15" RowDefinitions="Auto,Auto,Auto,*,Auto">
|
<Grid Margin="15" RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||||
<!-- Store Path -->
|
<!-- Store Path -->
|
||||||
@@ -87,7 +85,7 @@
|
|||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
|
<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" />
|
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using JdeScoping.SecureStoreManager.Constants;
|
||||||
using JdeScoping.SecureStoreManager.ViewModels;
|
using JdeScoping.SecureStoreManager.ViewModels;
|
||||||
using MsBox.Avalonia;
|
using MsBox.Avalonia;
|
||||||
using MsBox.Avalonia.Enums;
|
using MsBox.Avalonia.Enums;
|
||||||
@@ -14,6 +15,7 @@ public partial class OpenStoreDialog : Window
|
|||||||
public OpenStoreDialog()
|
public OpenStoreDialog()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
DataContext = new OpenStoreDialogViewModel();
|
||||||
Loaded += OpenStoreDialog_Loaded;
|
Loaded += OpenStoreDialog_Loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ public partial class OpenStoreDialog : Window
|
|||||||
FileTypeFilter = new[]
|
FileTypeFilter = new[]
|
||||||
{
|
{
|
||||||
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
|
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
|
var box = MessageBoxManager
|
||||||
.GetMessageBoxStandard(
|
.GetMessageBoxStandard(
|
||||||
"Validation Error",
|
DialogStrings.ValidationErrorTitle,
|
||||||
ViewModel.ValidationError ?? "Please fill in all required fields.",
|
ViewModel.ValidationError ?? DialogStrings.DefaultValidationError,
|
||||||
ButtonEnum.Ok,
|
ButtonEnum.Ok,
|
||||||
MsBox.Avalonia.Enums.Icon.Warning);
|
MsBox.Avalonia.Enums.Icon.Warning);
|
||||||
await box.ShowWindowDialogAsync(this);
|
await box.ShowWindowDialogAsync(this);
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
ShowInTaskbar="False">
|
ShowInTaskbar="False">
|
||||||
<Window.DataContext>
|
<!-- DataContext is set in code-behind -->
|
||||||
<vm:SecretEditDialogViewModel />
|
|
||||||
</Window.DataContext>
|
|
||||||
|
|
||||||
<Grid Margin="15" RowDefinitions="Auto,Auto,*,Auto">
|
<Grid Margin="15" RowDefinitions="Auto,Auto,*,Auto">
|
||||||
<!-- Key -->
|
<!-- Key -->
|
||||||
@@ -43,7 +41,7 @@
|
|||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
|
<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" />
|
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ public partial class SecretEditDialog : Window
|
|||||||
public SecretEditDialog()
|
public SecretEditDialog()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
DataContext = new SecretEditDialogViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
public SecretEditDialog(string key, string value) : this()
|
public SecretEditDialog(string key, string value)
|
||||||
{
|
{
|
||||||
|
InitializeComponent();
|
||||||
DataContext = new SecretEditDialogViewModel(key, value);
|
DataContext = new SecretEditDialogViewModel(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ public class ScheduleCheckerTests
|
|||||||
tasks[0].MinimumDt.ShouldNotBeNull();
|
tasks[0].MinimumDt.ShouldNotBeNull();
|
||||||
|
|
||||||
// Expected: lastDaily.EndDT - (3 * 1440 min) = lastDaily.EndDT - 3 days
|
// Expected: lastDaily.EndDT - (3 * 1440 min) = lastDaily.EndDT - 3 days
|
||||||
var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-3 * 1440);
|
var expectedMinimumDt = lastDaily.EndDt!.Value.AddMinutes(-3 * 1440);
|
||||||
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
|
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ public class ScheduleCheckerTests
|
|||||||
tasks[0].MinimumDt.ShouldNotBeNull();
|
tasks[0].MinimumDt.ShouldNotBeNull();
|
||||||
|
|
||||||
// Hourly uses hourly's timestamp and hourly's interval for lookback calculation
|
// Hourly uses hourly's timestamp and hourly's interval for lookback calculation
|
||||||
var expectedMinimumDt = lastHourly.EndDt.AddMinutes(-3 * 60);
|
var expectedMinimumDt = lastHourly.EndDt!.Value.AddMinutes(-3 * 60);
|
||||||
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
|
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ public class ScheduleCheckerTests
|
|||||||
var tasks = await _sut.GetPendingTasksAsync();
|
var tasks = await _sut.GetPendingTasksAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-5 * 1440);
|
var expectedMinimumDt = lastDaily.EndDt!.Value.AddMinutes(-5 * 1440);
|
||||||
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
|
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -9,12 +9,16 @@ namespace JdeScoping.SecureStoreManager.Tests.ViewModels;
|
|||||||
public class MainWindowViewModelTests
|
public class MainWindowViewModelTests
|
||||||
{
|
{
|
||||||
private readonly ISecureStoreManager _mockStoreManager;
|
private readonly ISecureStoreManager _mockStoreManager;
|
||||||
|
private readonly IDialogService _mockDialogService;
|
||||||
|
private readonly IClipboardService _mockClipboardService;
|
||||||
private readonly MainWindowViewModel _sut;
|
private readonly MainWindowViewModel _sut;
|
||||||
|
|
||||||
public MainWindowViewModelTests()
|
public MainWindowViewModelTests()
|
||||||
{
|
{
|
||||||
_mockStoreManager = Substitute.For<ISecureStoreManager>();
|
_mockStoreManager = Substitute.For<ISecureStoreManager>();
|
||||||
_sut = new MainWindowViewModel(_mockStoreManager);
|
_mockDialogService = Substitute.For<IDialogService>();
|
||||||
|
_mockClipboardService = Substitute.For<IClipboardService>();
|
||||||
|
_sut = new MainWindowViewModel(_mockStoreManager, _mockDialogService, _mockClipboardService);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -280,7 +284,7 @@ public class MainWindowViewModelTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
_sut.SelectedSecret = new SecretItemViewModel("key", "value");
|
_sut.SelectedSecret = new SecretItemViewModel("key", "value", _mockClipboardService);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
propertyChangedRaised.ShouldBeTrue();
|
propertyChangedRaised.ShouldBeTrue();
|
||||||
|
|||||||
+22
-19
@@ -1,16 +1,25 @@
|
|||||||
|
using NSubstitute;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using JdeScoping.SecureStoreManager.Services;
|
||||||
using JdeScoping.SecureStoreManager.ViewModels;
|
using JdeScoping.SecureStoreManager.ViewModels;
|
||||||
|
|
||||||
namespace JdeScoping.SecureStoreManager.Tests.ViewModels;
|
namespace JdeScoping.SecureStoreManager.Tests.ViewModels;
|
||||||
|
|
||||||
public class SecretItemViewModelTests
|
public class SecretItemViewModelTests
|
||||||
{
|
{
|
||||||
|
private readonly IClipboardService _mockClipboardService;
|
||||||
|
|
||||||
|
public SecretItemViewModelTests()
|
||||||
|
{
|
||||||
|
_mockClipboardService = Substitute.For<IClipboardService>();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_InitializesKey()
|
public void Constructor_InitializesKey()
|
||||||
{
|
{
|
||||||
// Arrange & Act
|
// Arrange & Act
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
sut.Key.ShouldBe("testKey");
|
sut.Key.ShouldBe("testKey");
|
||||||
@@ -20,7 +29,7 @@ public class SecretItemViewModelTests
|
|||||||
public void Constructor_InitializesActualValue()
|
public void Constructor_InitializesActualValue()
|
||||||
{
|
{
|
||||||
// Arrange & Act
|
// Arrange & Act
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
sut.ActualValue.ShouldBe("testValue");
|
sut.ActualValue.ShouldBe("testValue");
|
||||||
@@ -30,7 +39,7 @@ public class SecretItemViewModelTests
|
|||||||
public void Constructor_InitializesIsValueVisibleToFalse()
|
public void Constructor_InitializesIsValueVisibleToFalse()
|
||||||
{
|
{
|
||||||
// Arrange & Act
|
// Arrange & Act
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
sut.IsValueVisible.ShouldBeFalse();
|
sut.IsValueVisible.ShouldBeFalse();
|
||||||
@@ -40,7 +49,7 @@ public class SecretItemViewModelTests
|
|||||||
public void DisplayValue_WhenNotVisible_ReturnsMaskedValue()
|
public void DisplayValue_WhenNotVisible_ReturnsMaskedValue()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
sut.DisplayValue.ShouldBe("********");
|
sut.DisplayValue.ShouldBe("********");
|
||||||
@@ -50,7 +59,7 @@ public class SecretItemViewModelTests
|
|||||||
public void DisplayValue_WhenVisible_ReturnsActualValue()
|
public void DisplayValue_WhenVisible_ReturnsActualValue()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
sut.IsValueVisible = true;
|
sut.IsValueVisible = true;
|
||||||
@@ -63,7 +72,7 @@ public class SecretItemViewModelTests
|
|||||||
public void ToggleVisibilityCommand_TogglesIsValueVisible()
|
public void ToggleVisibilityCommand_TogglesIsValueVisible()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
sut.IsValueVisible.ShouldBeFalse();
|
sut.IsValueVisible.ShouldBeFalse();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -77,7 +86,7 @@ public class SecretItemViewModelTests
|
|||||||
public void ToggleVisibilityCommand_TogglesBackToHidden()
|
public void ToggleVisibilityCommand_TogglesBackToHidden()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
sut.IsValueVisible = true;
|
sut.IsValueVisible = true;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -88,16 +97,10 @@ public class SecretItemViewModelTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CopyToClipboardCommand_RaisesOnCopyToClipboardEvent()
|
public async Task CopyToClipboardCommand_CallsClipboardService()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var sut = new SecretItemViewModel("testKey", "secretPassword");
|
var sut = new SecretItemViewModel("testKey", "secretPassword", _mockClipboardService);
|
||||||
string? copiedValue = null;
|
|
||||||
sut.OnCopyToClipboard += value =>
|
|
||||||
{
|
|
||||||
copiedValue = value;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
sut.CopyToClipboardCommand.Execute(null);
|
sut.CopyToClipboardCommand.Execute(null);
|
||||||
@@ -105,14 +108,14 @@ public class SecretItemViewModelTests
|
|||||||
// Assert - need to wait for async handler
|
// Assert - need to wait for async handler
|
||||||
// Give the async void handler time to complete
|
// Give the async void handler time to complete
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
copiedValue.ShouldBe("secretPassword");
|
await _mockClipboardService.Received(1).SetTextAsync("secretPassword");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsValueVisible_SetterRaisesPropertyChangedForIsValueVisible()
|
public void IsValueVisible_SetterRaisesPropertyChangedForIsValueVisible()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
var propertyChangedRaised = false;
|
var propertyChangedRaised = false;
|
||||||
sut.PropertyChanged += (s, e) =>
|
sut.PropertyChanged += (s, e) =>
|
||||||
{
|
{
|
||||||
@@ -131,7 +134,7 @@ public class SecretItemViewModelTests
|
|||||||
public void IsValueVisible_SetterRaisesPropertyChangedForDisplayValue()
|
public void IsValueVisible_SetterRaisesPropertyChangedForDisplayValue()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
var propertyChangedRaised = false;
|
var propertyChangedRaised = false;
|
||||||
sut.PropertyChanged += (s, e) =>
|
sut.PropertyChanged += (s, e) =>
|
||||||
{
|
{
|
||||||
@@ -150,7 +153,7 @@ public class SecretItemViewModelTests
|
|||||||
public void IsValueVisible_SetToSameValue_DoesNotRaisePropertyChanged()
|
public void IsValueVisible_SetToSameValue_DoesNotRaisePropertyChanged()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var sut = new SecretItemViewModel("testKey", "testValue");
|
var sut = new SecretItemViewModel("testKey", "testValue", _mockClipboardService);
|
||||||
sut.IsValueVisible = false; // Already false, but explicitly set
|
sut.IsValueVisible = false; // Already false, but explicitly set
|
||||||
var propertyChangedRaised = false;
|
var propertyChangedRaised = false;
|
||||||
sut.PropertyChanged += (s, e) =>
|
sut.PropertyChanged += (s, e) =>
|
||||||
|
|||||||
@@ -12,14 +12,27 @@ namespace JdeScoping.SecureStoreManager.Tests.Views;
|
|||||||
|
|
||||||
public class MainWindowTests
|
public class MainWindowTests
|
||||||
{
|
{
|
||||||
|
private static MainWindowViewModel CreateViewModel(ISecureStoreManager? storeManager = null)
|
||||||
|
{
|
||||||
|
var mockStoreManager = storeManager ?? Substitute.For<ISecureStoreManager>();
|
||||||
|
var mockDialogService = Substitute.For<IDialogService>();
|
||||||
|
var mockClipboardService = Substitute.For<IClipboardService>();
|
||||||
|
return new MainWindowViewModel(mockStoreManager, mockDialogService, mockClipboardService);
|
||||||
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void MainWindow_ShowsWithCorrectDefaultTitle()
|
public void MainWindow_ShowsWithCorrectDefaultTitle()
|
||||||
{
|
{
|
||||||
// Arrange & Act
|
// Arrange
|
||||||
var window = new MainWindow();
|
var storeManager = Substitute.For<ISecureStoreManager>();
|
||||||
|
storeManager.IsStoreOpen.Returns(false);
|
||||||
|
var viewModel = CreateViewModel(storeManager);
|
||||||
|
var window = new MainWindow { DataContext = viewModel };
|
||||||
|
|
||||||
|
// Act
|
||||||
window.Show();
|
window.Show();
|
||||||
|
|
||||||
// Assert
|
// Assert - Title is bound to WindowTitle which is "SecureStore Manager" when no store is open
|
||||||
window.Title.ShouldBe("SecureStore Manager");
|
window.Title.ShouldBe("SecureStore Manager");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +61,14 @@ public class MainWindowTests
|
|||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void MainWindow_DataContextIsMainWindowViewModel()
|
public void MainWindow_DataContextIsMainWindowViewModel()
|
||||||
{
|
{
|
||||||
// Arrange & Act
|
// Arrange
|
||||||
var window = new MainWindow();
|
var viewModel = CreateViewModel();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var window = new MainWindow { DataContext = viewModel };
|
||||||
window.Show();
|
window.Show();
|
||||||
|
|
||||||
// Assert
|
// Assert - DataContext is set via DI in production, but must be set manually in tests
|
||||||
window.DataContext.ShouldBeOfType<MainWindowViewModel>();
|
window.DataContext.ShouldBeOfType<MainWindowViewModel>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,31 +119,36 @@ public class MainWindowTests
|
|||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void MainWindow_NewButtonCommand_IsBoundToViewModel()
|
public void MainWindow_NewButtonCommand_IsBoundToViewModel()
|
||||||
{
|
{
|
||||||
// Arrange & Act
|
// Arrange
|
||||||
var window = new MainWindow();
|
var storeManager = Substitute.For<ISecureStoreManager>();
|
||||||
|
var viewModel = CreateViewModel(storeManager);
|
||||||
|
var window = new MainWindow { DataContext = viewModel };
|
||||||
|
|
||||||
|
// Act
|
||||||
window.Show();
|
window.Show();
|
||||||
|
|
||||||
var viewModel = window.DataContext as MainWindowViewModel;
|
|
||||||
var buttons = window.GetVisualDescendants().OfType<Button>().ToList();
|
var buttons = window.GetVisualDescendants().OfType<Button>().ToList();
|
||||||
var newButton = buttons.FirstOrDefault(b => b.Content?.ToString() == "New");
|
var newButton = buttons.FirstOrDefault(b => b.Content?.ToString() == "New");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
newButton.ShouldNotBeNull();
|
newButton.ShouldNotBeNull();
|
||||||
newButton.Command.ShouldBe(viewModel?.NewStoreCommand);
|
newButton.Command.ShouldBe(viewModel.NewStoreCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void MainWindow_StatusBar_DisplaysStatusMessage()
|
public void MainWindow_StatusBar_DisplaysStatusMessage()
|
||||||
{
|
{
|
||||||
// Arrange & Act
|
// Arrange
|
||||||
var window = new MainWindow();
|
var storeManager = Substitute.For<ISecureStoreManager>();
|
||||||
window.Show();
|
var viewModel = CreateViewModel(storeManager);
|
||||||
|
var window = new MainWindow { DataContext = viewModel };
|
||||||
|
|
||||||
var viewModel = window.DataContext as MainWindowViewModel;
|
// Act
|
||||||
|
window.Show();
|
||||||
|
|
||||||
// Assert - Find status bar text blocks
|
// Assert - Find status bar text blocks
|
||||||
var textBlocks = window.GetVisualDescendants().OfType<TextBlock>().ToList();
|
var textBlocks = window.GetVisualDescendants().OfType<TextBlock>().ToList();
|
||||||
// Status message should be "Ready" by default
|
// Status message should be "Ready" by default
|
||||||
textBlocks.Any(tb => tb.Text == viewModel?.StatusMessage).ShouldBeTrue();
|
textBlocks.Any(tb => tb.Text == viewModel.StatusMessage).ShouldBeTrue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user