refactor: address code review findings across all projects

Apply comprehensive fixes from code reviews including:
- Extract shared utilities (SqlFormatHelper, CellValueConverter, DbDestinationBase)
- Add interface abstractions (IAuthenticationService, IDatabaseMigrator, IMisQueryBuilder)
- Implement SecureStore for encrypted secrets storage
- Fix error handling with proper HTTP status codes and logging
- Optimize double enumeration in DevEtlRegistry
- Add DataSync.Dev README for developer onboarding
- Extract filter panel base classes to reduce duplication
- Update code review docs to mark all issues as fixed
This commit is contained in:
Joseph Doherty
2026-01-19 11:05:36 -05:00
parent 08f5aa1447
commit 604bfe919c
148 changed files with 8696 additions and 1538 deletions
@@ -0,0 +1,19 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:JdeScoping.SecureStoreManager.Converters"
x:Class="JdeScoping.SecureStoreManager.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
<Application.Resources>
<ResourceDictionary>
<!-- Converters -->
<converters:InverseBooleanConverter x:Key="InverseBool" />
<converters:BooleanToVisibilityIconConverter x:Key="BoolToVisibilityIcon" />
<converters:NullToBoolConverter x:Key="NullToBool" />
<converters:StringToBoolConverter x:Key="StringToBool" />
</ResourceDictionary>
</Application.Resources>
</Application>
@@ -0,0 +1,24 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using JdeScoping.SecureStoreManager.Views;
namespace JdeScoping.SecureStoreManager;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}
@@ -0,0 +1,85 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace JdeScoping.SecureStoreManager.Converters;
/// <summary>
/// Inverts a boolean value.
/// </summary>
public class InverseBooleanConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool boolValue)
{
return !boolValue;
}
return false;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool boolValue)
{
return !boolValue;
}
return false;
}
}
/// <summary>
/// Converts a boolean to a visibility icon (eye open/closed).
/// </summary>
public class BooleanToVisibilityIconConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isVisible)
{
// Use simple text icons for cross-platform compatibility
return isVisible ? "Hide" : "Show";
}
return "Show";
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
/// <summary>
/// Converts null to bool (null = false, not null = true).
/// </summary>
public class NullToBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value != null;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
/// <summary>
/// Converts a string to bool (empty = false, not empty = true).
/// </summary>
public class StringToBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is string str)
{
return !string.IsNullOrWhiteSpace(str);
}
return false;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.*" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.*" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.*" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.*" />
<PackageReference Include="MessageBox.Avalonia" Version="3.1.*" />
<PackageReference Include="SecureStore" Version="1.2.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,17 @@
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,18 @@
using Avalonia;
namespace JdeScoping.SecureStoreManager;
internal class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
}
@@ -0,0 +1,170 @@
# JdeScoping SecureStore Manager
A cross-platform desktop utility for managing encrypted SecureStore secrets. This tool provides a graphical interface for creating, editing, and managing secrets stored in encrypted JSON files using the NeoSmart.SecureStore library.
## Features
- **Cross-platform** - Runs on Windows, macOS, and Linux
- **Create new stores** - Create encrypted secret stores with either key file or password-based encryption
- **Open existing stores** - Open and manage existing SecureStore JSON files
- **Manage secrets** - Add, edit, and delete key-value pairs
- **Masked values** - Secret values are masked by default with a reveal toggle
- **Copy to clipboard** - Quickly copy secret values
- **Unsaved changes tracking** - Prompts before closing with unsaved changes
- **Key file generation** - Generate standalone key files for deployment
## Building
### Prerequisites
- .NET 10.0 SDK or later
### Build and Run
```bash
# Build the application
dotnet build src/Utils/JdeScoping.SecureStoreManager
# Run the application
dotnet run --project src/Utils/JdeScoping.SecureStoreManager
```
### Platform-Specific Notes
#### Windows
No additional setup required.
#### macOS
No additional setup required. The application uses native macOS windowing.
#### Linux
Ensure you have the required GTK libraries installed:
```bash
# Ubuntu/Debian
sudo apt install libgtk-3-0
# Fedora
sudo dnf install gtk3
```
## Running Tests
```bash
dotnet test tests/JdeScoping.SecureStoreManager.Tests
```
## Usage
### Creating a New Store
1. Launch the application
2. Select **File > New Store** (or press `Ctrl+N`)
3. Choose the store location (`.json` file)
4. Select encryption method:
- **Key File** (recommended for production): Generates a `.key` file that must be kept secure
- **Password**: Uses a password for encryption
5. Click **Create**
### Opening an Existing Store
1. Select **File > Open Store** (or press `Ctrl+O`)
2. Browse to the store file (`.json`)
3. Provide the decryption method:
- Browse to the key file, or
- Enter the password
4. Click **Open**
### Managing Secrets
| Action | How To |
|--------|--------|
| Add secret | **Secrets > Add Secret** or toolbar **Add** button |
| Edit secret | Double-click the row, or select and press **Enter** |
| Delete secret | Select the row and press **Delete** |
| Reveal value | Click the **Show/Hide** button in the Actions column |
| Copy value | Click **Copy** in the Actions column |
| Save changes | **File > Save** or press `Ctrl+S` |
### Generating a Standalone Key File
For deployment scenarios where you need to pre-generate a key file:
1. Select **Tools > Generate Key File**
2. Choose the save location
3. The generated key can be used with the main JdeScoping application
### Exporting the Current Key
To backup or copy the key from the currently open store:
1. Open a store that uses key file encryption
2. Select **Tools > Export Current Key**
3. Choose the export location
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+N` | New Store |
| `Ctrl+O` | Open Store |
| `Ctrl+S` | Save |
| `Ctrl+W` | Close Store |
| `Delete` | Delete selected secret |
## Integration with JdeScoping
This utility is compatible with the SecureStore format used by the main JdeScoping application. You can use it to:
- View and edit secrets in the application's `data/secrets.json` file
- Pre-configure secrets before deployment
- Migrate secrets between environments
- Troubleshoot configuration issues
### Opening the Main Application's Store
1. Locate the store file: `data/secrets.json` (relative to the JdeScoping.Host executable)
2. Locate the key file: `data/secrets.key` (or use the master key password if configured)
3. Open the store using this utility
## Security Considerations
- **Key files** should be treated as sensitive credentials and not committed to source control
- **Values are masked** by default to prevent shoulder surfing
- **No auto-save** - changes must be explicitly saved to prevent accidental overwrites
- **Delete confirmation** - deleting secrets requires confirmation
- **Unsaved changes prompt** - closing with unsaved changes prompts the user
## Project Structure
```
JdeScoping.SecureStoreManager/
├── Models/
│ └── SecretEntry.cs # Secret key-value model
├── Services/
│ ├── ISecureStoreManager.cs # Service interface
│ └── SecureStoreManager.cs # SecureStore wrapper implementation
├── ViewModels/
│ ├── ViewModelBase.cs # INotifyPropertyChanged base
│ ├── RelayCommand.cs # ICommand implementation
│ ├── MainWindowViewModel.cs # Main window logic
│ ├── SecretItemViewModel.cs # Individual secret item
│ └── DialogViewModels.cs # Dialog view models
├── Views/
│ ├── MainWindow.axaml # Main application window
│ ├── NewStoreDialog.axaml # Create store dialog
│ ├── OpenStoreDialog.axaml # Open store dialog
│ └── SecretEditDialog.axaml # Add/edit secret dialog
├── Converters/
│ └── BooleanConverters.cs # Value converters
├── App.axaml # Application resources
├── Program.cs # Application entry point
└── README.md # This file
```
## Dependencies
- .NET 10.0
- Avalonia UI 11.2
- Avalonia.Controls.DataGrid 11.2
- MessageBox.Avalonia 3.1
- NeoSmart.SecureStore 1.2.0
@@ -0,0 +1,98 @@
namespace JdeScoping.SecureStoreManager.Services;
/// <summary>
/// Interface for managing SecureStore encrypted secret stores.
/// </summary>
public interface ISecureStoreManager
{
/// <summary>
/// Gets whether a store is currently open.
/// </summary>
bool IsStoreOpen { get; }
/// <summary>
/// Gets the path to the currently open store, or null if no store is open.
/// </summary>
string? CurrentStorePath { get; }
/// <summary>
/// Gets whether there are unsaved changes to the current store.
/// </summary>
bool HasUnsavedChanges { get; }
/// <summary>
/// Creates a new store secured with a key file.
/// </summary>
/// <param name="storePath">Path for the new store file (.json).</param>
/// <param name="keyFilePath">Path for the key file (.key).</param>
void CreateStore(string storePath, string keyFilePath);
/// <summary>
/// Creates a new store secured with a password.
/// </summary>
/// <param name="storePath">Path for the new store file (.json).</param>
/// <param name="password">Password to encrypt the store.</param>
void CreateStoreWithPassword(string storePath, string password);
/// <summary>
/// Opens an existing store using a key file.
/// </summary>
/// <param name="storePath">Path to the store file (.json).</param>
/// <param name="keyFilePath">Path to the key file (.key).</param>
void OpenStore(string storePath, string keyFilePath);
/// <summary>
/// Opens an existing store using a password.
/// </summary>
/// <param name="storePath">Path to the store file (.json).</param>
/// <param name="password">Password to decrypt the store.</param>
void OpenStoreWithPassword(string storePath, string password);
/// <summary>
/// Closes the currently open store without saving.
/// </summary>
void CloseStore();
/// <summary>
/// Saves changes to the currently open store.
/// </summary>
void Save();
/// <summary>
/// Gets all secret keys in the current store.
/// </summary>
/// <returns>Collection of secret key names.</returns>
IReadOnlyList<string> GetKeys();
/// <summary>
/// Gets the value of a secret.
/// </summary>
/// <param name="key">The secret key.</param>
/// <returns>The decrypted secret value.</returns>
string GetSecret(string key);
/// <summary>
/// Sets or updates a secret value.
/// </summary>
/// <param name="key">The secret key.</param>
/// <param name="value">The value to encrypt and store.</param>
void SetSecret(string key, string value);
/// <summary>
/// Removes a secret from the store.
/// </summary>
/// <param name="key">The secret key to remove.</param>
void RemoveSecret(string key);
/// <summary>
/// Generates a new key file for use with store encryption.
/// </summary>
/// <param name="path">Path where the key file will be created.</param>
void GenerateKeyFile(string path);
/// <summary>
/// Exports the current store's key to a file (for key file-based stores).
/// </summary>
/// <param name="path">Path where the key will be exported.</param>
void ExportKey(string path);
}
@@ -0,0 +1,285 @@
using System.IO;
using System.Text.Json;
using NeoSmart.SecureStore;
namespace JdeScoping.SecureStoreManager.Services;
/// <summary>
/// Manages SecureStore encrypted secret stores for the WPF application.
/// </summary>
public class SecureStoreManager : ISecureStoreManager, IDisposable
{
private SecretsManager? _secretsManager;
private string? _currentStorePath;
private readonly HashSet<string> _keys = new();
private bool _hasUnsavedChanges;
private bool _disposed;
private const string KeysMetadataKey = "__keys__";
/// <inheritdoc />
public bool IsStoreOpen => _secretsManager != null;
/// <inheritdoc />
public string? CurrentStorePath => _currentStorePath;
/// <inheritdoc />
public bool HasUnsavedChanges => _hasUnsavedChanges;
/// <inheritdoc />
public void CreateStore(string storePath, string keyFilePath)
{
ThrowIfDisposed();
CloseStoreInternal();
EnsureDirectory(storePath);
EnsureDirectory(keyFilePath);
_secretsManager = SecretsManager.CreateStore();
_secretsManager.GenerateKey();
_secretsManager.ExportKey(keyFilePath);
_currentStorePath = storePath;
_keys.Clear();
_hasUnsavedChanges = true;
Save();
}
/// <inheritdoc />
public void CreateStoreWithPassword(string storePath, string password)
{
ThrowIfDisposed();
CloseStoreInternal();
if (string.IsNullOrEmpty(password))
throw new ArgumentException("Password cannot be empty.", nameof(password));
EnsureDirectory(storePath);
_secretsManager = SecretsManager.CreateStore();
_secretsManager.LoadKeyFromPassword(password);
_currentStorePath = storePath;
_keys.Clear();
_hasUnsavedChanges = true;
Save();
}
/// <inheritdoc />
public void OpenStore(string storePath, string keyFilePath)
{
ThrowIfDisposed();
CloseStoreInternal();
if (!File.Exists(storePath))
throw new FileNotFoundException("Store file not found.", storePath);
if (!File.Exists(keyFilePath))
throw new FileNotFoundException("Key file not found.", keyFilePath);
_secretsManager = SecretsManager.LoadStore(storePath);
_secretsManager.LoadKeyFromFile(keyFilePath);
_currentStorePath = storePath;
LoadKeysMetadata();
_hasUnsavedChanges = false;
}
/// <inheritdoc />
public void OpenStoreWithPassword(string storePath, string password)
{
ThrowIfDisposed();
CloseStoreInternal();
if (!File.Exists(storePath))
throw new FileNotFoundException("Store file not found.", storePath);
if (string.IsNullOrEmpty(password))
throw new ArgumentException("Password cannot be empty.", nameof(password));
_secretsManager = SecretsManager.LoadStore(storePath);
_secretsManager.LoadKeyFromPassword(password);
_currentStorePath = storePath;
LoadKeysMetadata();
_hasUnsavedChanges = false;
}
/// <inheritdoc />
public void CloseStore()
{
ThrowIfDisposed();
CloseStoreInternal();
}
/// <inheritdoc />
public void Save()
{
ThrowIfDisposed();
if (_secretsManager == null || _currentStorePath == null)
throw new InvalidOperationException("No store is currently open.");
SaveKeysMetadata();
_secretsManager.SaveStore(_currentStorePath);
_hasUnsavedChanges = false;
}
/// <inheritdoc />
public IReadOnlyList<string> GetKeys()
{
ThrowIfDisposed();
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
return _keys.Where(k => k != KeysMetadataKey).ToList().AsReadOnly();
}
/// <inheritdoc />
public string GetSecret(string key)
{
ThrowIfDisposed();
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be empty.", nameof(key));
if (!_keys.Contains(key))
throw new KeyNotFoundException($"Secret '{key}' not found.");
return _secretsManager.Get(key);
}
/// <inheritdoc />
public void SetSecret(string key, string value)
{
ThrowIfDisposed();
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be empty.", nameof(key));
_secretsManager.Set(key, value ?? string.Empty);
_keys.Add(key);
_hasUnsavedChanges = true;
}
/// <inheritdoc />
public void RemoveSecret(string key)
{
ThrowIfDisposed();
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be empty.", nameof(key));
if (!_keys.Remove(key))
throw new KeyNotFoundException($"Secret '{key}' not found.");
_secretsManager.Delete(key);
_hasUnsavedChanges = true;
}
/// <inheritdoc />
public void GenerateKeyFile(string path)
{
ThrowIfDisposed();
if (string.IsNullOrEmpty(path))
throw new ArgumentException("Path cannot be empty.", nameof(path));
EnsureDirectory(path);
using var tempManager = SecretsManager.CreateStore();
tempManager.GenerateKey();
tempManager.ExportKey(path);
}
/// <inheritdoc />
public void ExportKey(string path)
{
ThrowIfDisposed();
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
if (string.IsNullOrEmpty(path))
throw new ArgumentException("Path cannot be empty.", nameof(path));
EnsureDirectory(path);
_secretsManager.ExportKey(path);
}
private void LoadKeysMetadata()
{
_keys.Clear();
try
{
var keysJson = _secretsManager!.Get(KeysMetadataKey);
if (!string.IsNullOrEmpty(keysJson))
{
var keys = JsonSerializer.Deserialize<string[]>(keysJson);
if (keys != null)
{
foreach (var key in keys)
_keys.Add(key);
}
}
}
catch (KeyNotFoundException)
{
// No keys metadata yet
}
}
private void SaveKeysMetadata()
{
var keys = _keys.Where(k => k != KeysMetadataKey).ToArray();
var keysJson = JsonSerializer.Serialize(keys);
_secretsManager!.Set(KeysMetadataKey, keysJson);
_keys.Add(KeysMetadataKey);
}
private void CloseStoreInternal()
{
_secretsManager?.Dispose();
_secretsManager = null;
_currentStorePath = null;
_keys.Clear();
_hasUnsavedChanges = false;
}
private static void EnsureDirectory(string filePath)
{
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
public void Dispose()
{
if (_disposed) return;
_secretsManager?.Dispose();
_secretsManager = null;
_disposed = true;
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,337 @@
using System.Windows.Input;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// View model for creating a new store.
/// </summary>
public class NewStoreDialogViewModel : ViewModelBase
{
private string _storePath = string.Empty;
private string _keyFilePath = string.Empty;
private string _password = string.Empty;
private string _confirmPassword = string.Empty;
private bool _useKeyFile = true;
private bool _usePassword;
public NewStoreDialogViewModel()
{
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
}
public string StorePath
{
get => _storePath;
set => SetProperty(ref _storePath, value);
}
public string KeyFilePath
{
get => _keyFilePath;
set => SetProperty(ref _keyFilePath, value);
}
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
}
public string ConfirmPassword
{
get => _confirmPassword;
set => SetProperty(ref _confirmPassword, value);
}
public bool UseKeyFile
{
get => _useKeyFile;
set
{
if (SetProperty(ref _useKeyFile, value))
{
if (value) UsePassword = false;
}
}
}
public bool UsePassword
{
get => _usePassword;
set
{
if (SetProperty(ref _usePassword, value))
{
if (value) UseKeyFile = false;
}
}
}
public ICommand BrowseStorePathCommand { get; }
public ICommand BrowseKeyFilePathCommand { get; }
public bool IsValid
{
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return false;
if (UseKeyFile)
return !string.IsNullOrWhiteSpace(KeyFilePath);
if (UsePassword)
return !string.IsNullOrWhiteSpace(Password) && Password == ConfirmPassword;
return false;
}
}
public string? ValidationError
{
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return "Store path is required.";
if (UseKeyFile && string.IsNullOrWhiteSpace(KeyFilePath))
return "Key file path is required.";
if (UsePassword)
{
if (string.IsNullOrWhiteSpace(Password))
return "Password is required.";
if (Password != ConfirmPassword)
return "Passwords do not match.";
}
return null;
}
}
/// <summary>
/// Event raised to request save file dialog for store path.
/// Parameters: title, fileTypeName, pattern, defaultExtension
/// Returns: selected file path or null
/// </summary>
public event Func<string, string, string, string, Task<string?>>? OnShowSaveFileDialog;
private async void BrowseStorePath()
{
if (OnShowSaveFileDialog == null)
return;
var path = await OnShowSaveFileDialog("Choose Store Location", "SecureStore Files", "*.json", ".json");
if (!string.IsNullOrEmpty(path))
{
StorePath = path;
}
}
private async void BrowseKeyFilePath()
{
if (OnShowSaveFileDialog == null)
return;
var path = await OnShowSaveFileDialog("Choose Key File Location", "Key Files", "*.key", ".key");
if (!string.IsNullOrEmpty(path))
{
KeyFilePath = path;
}
}
}
/// <summary>
/// View model for opening an existing store.
/// </summary>
public class OpenStoreDialogViewModel : ViewModelBase
{
private string _storePath = string.Empty;
private string _keyFilePath = string.Empty;
private string _password = string.Empty;
private bool _useKeyFile = true;
private bool _usePassword;
public OpenStoreDialogViewModel()
{
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
}
public string StorePath
{
get => _storePath;
set => SetProperty(ref _storePath, value);
}
public string KeyFilePath
{
get => _keyFilePath;
set => SetProperty(ref _keyFilePath, value);
}
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
}
public bool UseKeyFile
{
get => _useKeyFile;
set
{
if (SetProperty(ref _useKeyFile, value))
{
if (value) UsePassword = false;
}
}
}
public bool UsePassword
{
get => _usePassword;
set
{
if (SetProperty(ref _usePassword, value))
{
if (value) UseKeyFile = false;
}
}
}
public ICommand BrowseStorePathCommand { get; }
public ICommand BrowseKeyFilePathCommand { get; }
public bool IsValid
{
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return false;
if (UseKeyFile)
return !string.IsNullOrWhiteSpace(KeyFilePath);
if (UsePassword)
return !string.IsNullOrWhiteSpace(Password);
return false;
}
}
public string? ValidationError
{
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return "Store path is required.";
if (!System.IO.File.Exists(StorePath))
return "Store file does not exist.";
if (UseKeyFile)
{
if (string.IsNullOrWhiteSpace(KeyFilePath))
return "Key file path is required.";
if (!System.IO.File.Exists(KeyFilePath))
return "Key file does not exist.";
}
if (UsePassword && string.IsNullOrWhiteSpace(Password))
return "Password is required.";
return null;
}
}
/// <summary>
/// Event raised to request open file dialog for store path.
/// Parameters: title, fileTypeName, pattern
/// Returns: selected file path or null
/// </summary>
public event Func<string, string, string, Task<string?>>? OnShowOpenFileDialog;
private async void BrowseStorePath()
{
if (OnShowOpenFileDialog == null)
return;
var path = await OnShowOpenFileDialog("Select Store File", "SecureStore Files", "*.json");
if (!string.IsNullOrEmpty(path))
{
StorePath = path;
}
}
private async void BrowseKeyFilePath()
{
if (OnShowOpenFileDialog == null)
return;
var path = await OnShowOpenFileDialog("Select Key File", "Key Files", "*.key");
if (!string.IsNullOrEmpty(path))
{
KeyFilePath = path;
}
}
}
/// <summary>
/// View model for adding or editing a secret.
/// </summary>
public class SecretEditDialogViewModel : ViewModelBase
{
private string _key = string.Empty;
private string _value = string.Empty;
private bool _isNewSecret = true;
public SecretEditDialogViewModel()
{
}
public SecretEditDialogViewModel(string key, string value)
{
_key = key;
_value = value;
_isNewSecret = false;
}
public string Key
{
get => _key;
set => SetProperty(ref _key, value);
}
public string Value
{
get => _value;
set => SetProperty(ref _value, value);
}
public bool IsNewSecret
{
get => _isNewSecret;
set => SetProperty(ref _isNewSecret, value);
}
public bool IsKeyEditable => _isNewSecret;
public string DialogTitle => _isNewSecret ? "Add Secret" : "Edit Secret";
public bool IsValid => !string.IsNullOrWhiteSpace(Key);
public string? ValidationError
{
get
{
if (string.IsNullOrWhiteSpace(Key))
return "Key is required.";
return null;
}
}
}
@@ -0,0 +1,417 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using JdeScoping.SecureStoreManager.Services;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// Main window view model containing all application logic.
/// </summary>
public class MainWindowViewModel : ViewModelBase
{
private readonly ISecureStoreManager _storeManager;
private SecretItemViewModel? _selectedSecret;
private string _statusMessage = "Ready";
public MainWindowViewModel() : this(new Services.SecureStoreManager())
{
}
public MainWindowViewModel(ISecureStoreManager storeManager)
{
_storeManager = storeManager;
Secrets = new ObservableCollection<SecretItemViewModel>();
// File commands
NewStoreCommand = new RelayCommand(ExecuteNewStore);
OpenStoreCommand = new RelayCommand(ExecuteOpenStore);
SaveCommand = new RelayCommand(ExecuteSave, CanSave);
CloseStoreCommand = new RelayCommand(ExecuteCloseStore, () => _storeManager.IsStoreOpen);
ExitCommand = new RelayCommand(ExecuteExit);
// Secret commands
AddSecretCommand = new RelayCommand(ExecuteAddSecret, () => _storeManager.IsStoreOpen);
EditSecretCommand = new RelayCommand(ExecuteEditSecret, CanEditOrDeleteSecret);
DeleteSecretCommand = new RelayCommand(ExecuteDeleteSecret, CanEditOrDeleteSecret);
// Tools commands
GenerateKeyFileCommand = new RelayCommand(ExecuteGenerateKeyFile);
ExportKeyCommand = new RelayCommand(ExecuteExportKey, () => _storeManager.IsStoreOpen);
}
/// <summary>
/// Gets the collection of secrets in the current store.
/// </summary>
public ObservableCollection<SecretItemViewModel> Secrets { get; }
/// <summary>
/// Gets or sets the currently selected secret.
/// </summary>
public SecretItemViewModel? SelectedSecret
{
get => _selectedSecret;
set => SetProperty(ref _selectedSecret, value);
}
/// <summary>
/// Gets the current status message.
/// </summary>
public string StatusMessage
{
get => _statusMessage;
private set => SetProperty(ref _statusMessage, value);
}
/// <summary>
/// Gets the window title including the current store path.
/// </summary>
public string WindowTitle
{
get
{
var title = "SecureStore Manager";
if (_storeManager.IsStoreOpen)
{
title += $" - {_storeManager.CurrentStorePath}";
if (_storeManager.HasUnsavedChanges)
title += " *";
}
return title;
}
}
/// <summary>
/// Gets whether a store is currently open.
/// </summary>
public bool IsStoreOpen => _storeManager.IsStoreOpen;
/// <summary>
/// Gets whether there are unsaved changes.
/// </summary>
public bool HasUnsavedChanges => _storeManager.HasUnsavedChanges;
// File Commands
public ICommand NewStoreCommand { get; }
public ICommand OpenStoreCommand { get; }
public ICommand SaveCommand { get; }
public ICommand CloseStoreCommand { get; }
public ICommand ExitCommand { get; }
// Secret Commands
public ICommand AddSecretCommand { get; }
public ICommand EditSecretCommand { get; }
public ICommand DeleteSecretCommand { get; }
// Tools Commands
public ICommand GenerateKeyFileCommand { get; }
public ICommand ExportKeyCommand { get; }
/// <summary>
/// Creates a new store. Called by the dialog.
/// </summary>
public async Task CreateNewStoreAsync(string storePath, string? keyFilePath, string? password)
{
try
{
if (!string.IsNullOrEmpty(keyFilePath))
{
_storeManager.CreateStore(storePath, keyFilePath);
StatusMessage = $"Created store with key file: {keyFilePath}";
}
else if (!string.IsNullOrEmpty(password))
{
_storeManager.CreateStoreWithPassword(storePath, password);
StatusMessage = "Created password-protected store";
}
else
{
throw new ArgumentException("Either key file path or password must be provided.");
}
RefreshSecrets();
NotifyStoreChanged();
}
catch (Exception ex)
{
StatusMessage = $"Error creating store: {ex.Message}";
await (OnShowError?.Invoke($"Failed to create store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
/// <summary>
/// Opens an existing store. Called by the dialog.
/// </summary>
public async Task OpenExistingStoreAsync(string storePath, string? keyFilePath, string? password)
{
try
{
if (!string.IsNullOrEmpty(keyFilePath))
{
_storeManager.OpenStore(storePath, keyFilePath);
StatusMessage = "Opened store with key file";
}
else if (!string.IsNullOrEmpty(password))
{
_storeManager.OpenStoreWithPassword(storePath, password);
StatusMessage = "Opened password-protected store";
}
else
{
throw new ArgumentException("Either key file path or password must be provided.");
}
RefreshSecrets();
NotifyStoreChanged();
}
catch (Exception ex)
{
StatusMessage = $"Error opening store: {ex.Message}";
await (OnShowError?.Invoke($"Failed to open store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
/// <summary>
/// Adds or updates a secret. Called by the dialog.
/// </summary>
public async Task SaveSecretAsync(string key, string value, bool isNew)
{
try
{
_storeManager.SetSecret(key, value);
RefreshSecrets();
NotifyStoreChanged();
StatusMessage = isNew ? $"Added secret: {key}" : $"Updated secret: {key}";
}
catch (Exception ex)
{
StatusMessage = $"Error saving secret: {ex.Message}";
await (OnShowError?.Invoke($"Failed to save secret:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
/// <summary>
/// Checks for unsaved changes and prompts the user.
/// </summary>
/// <returns>True if it's safe to proceed, false if the user cancelled.</returns>
public async Task<bool> PromptForUnsavedChangesAsync()
{
if (!_storeManager.HasUnsavedChanges)
return true;
if (OnShowUnsavedChangesPrompt == null)
return true;
var result = await OnShowUnsavedChangesPrompt();
switch (result)
{
case UnsavedChangesResult.Save:
await ExecuteSaveAsync();
return true;
case UnsavedChangesResult.DontSave:
return true;
case UnsavedChangesResult.Cancel:
default:
return false;
}
}
private async void ExecuteNewStore()
{
if (!await PromptForUnsavedChangesAsync())
return;
// The view will show the NewStoreDialog
OnRequestNewStoreDialog?.Invoke();
}
private async void ExecuteOpenStore()
{
if (!await PromptForUnsavedChangesAsync())
return;
// The view will show the OpenStoreDialog
OnRequestOpenStoreDialog?.Invoke();
}
private async void ExecuteSave()
{
await ExecuteSaveAsync();
}
private async Task ExecuteSaveAsync()
{
try
{
_storeManager.Save();
NotifyStoreChanged();
StatusMessage = "Store saved";
}
catch (Exception ex)
{
StatusMessage = $"Error saving: {ex.Message}";
await (OnShowError?.Invoke($"Failed to save store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
private bool CanSave() => _storeManager.IsStoreOpen && _storeManager.HasUnsavedChanges;
private async void ExecuteCloseStore()
{
if (!await PromptForUnsavedChangesAsync())
return;
_storeManager.CloseStore();
Secrets.Clear();
NotifyStoreChanged();
StatusMessage = "Store closed";
}
private async void ExecuteExit()
{
if (!await PromptForUnsavedChangesAsync())
return;
OnRequestClose?.Invoke();
}
private void ExecuteAddSecret()
{
// The view will show the SecretEditDialog
OnRequestAddSecretDialog?.Invoke();
}
private void ExecuteEditSecret()
{
if (SelectedSecret == null)
return;
// The view will show the SecretEditDialog with existing values
OnRequestEditSecretDialog?.Invoke(SelectedSecret.Key, SelectedSecret.ActualValue);
}
private async void ExecuteDeleteSecret()
{
if (SelectedSecret == null)
return;
if (OnShowDeleteConfirmation == null)
return;
var confirmed = await OnShowDeleteConfirmation(SelectedSecret.Key);
if (!confirmed)
return;
try
{
var key = SelectedSecret.Key;
_storeManager.RemoveSecret(key);
RefreshSecrets();
NotifyStoreChanged();
StatusMessage = $"Deleted secret: {key}";
}
catch (Exception ex)
{
StatusMessage = $"Error deleting secret: {ex.Message}";
await (OnShowError?.Invoke($"Failed to delete secret:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
private bool CanEditOrDeleteSecret() => _storeManager.IsStoreOpen && SelectedSecret != null;
private async void ExecuteGenerateKeyFile()
{
if (OnShowSaveFileDialog == null)
return;
var filePath = await OnShowSaveFileDialog("Generate Key File", "Key Files", "*.key", ".key");
if (string.IsNullOrEmpty(filePath))
return;
try
{
_storeManager.GenerateKeyFile(filePath);
StatusMessage = $"Generated key file: {filePath}";
await (OnShowInfo?.Invoke($"Key file generated successfully:\n\n{filePath}", "Key Generated") ?? Task.CompletedTask);
}
catch (Exception ex)
{
StatusMessage = $"Error generating key: {ex.Message}";
await (OnShowError?.Invoke($"Failed to generate key file:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
private async void ExecuteExportKey()
{
if (OnShowSaveFileDialog == null)
return;
var filePath = await OnShowSaveFileDialog("Export Key", "Key Files", "*.key", ".key");
if (string.IsNullOrEmpty(filePath))
return;
try
{
_storeManager.ExportKey(filePath);
StatusMessage = $"Exported key to: {filePath}";
await (OnShowInfo?.Invoke($"Key exported successfully:\n\n{filePath}", "Key Exported") ?? Task.CompletedTask);
}
catch (Exception ex)
{
StatusMessage = $"Error exporting key: {ex.Message}";
await (OnShowError?.Invoke($"Failed to export key:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
private void RefreshSecrets()
{
Secrets.Clear();
if (!_storeManager.IsStoreOpen)
return;
foreach (var key in _storeManager.GetKeys())
{
var value = _storeManager.GetSecret(key);
Secrets.Add(new SecretItemViewModel(key, value));
}
}
private void NotifyStoreChanged()
{
OnPropertyChanged(nameof(IsStoreOpen));
OnPropertyChanged(nameof(HasUnsavedChanges));
OnPropertyChanged(nameof(WindowTitle));
// Manually raise CanExecuteChanged for all commands
(SaveCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CloseStoreCommand as RelayCommand)?.RaiseCanExecuteChanged();
(AddSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(DeleteSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(ExportKeyCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
// Events for view to show dialogs (sync)
public event Action? OnRequestNewStoreDialog;
public event Action? OnRequestOpenStoreDialog;
public event Action? OnRequestAddSecretDialog;
public event Action<string, string>? OnRequestEditSecretDialog;
public event Action? OnRequestClose;
// Events for view to show dialogs (async)
public event Func<string, string, Task>? OnShowError;
public event Func<string, string, Task>? OnShowInfo;
public event Func<Task<UnsavedChangesResult>>? OnShowUnsavedChangesPrompt;
public event Func<string, Task<bool>>? OnShowDeleteConfirmation;
public event Func<string, string, string, string, Task<string?>>? OnShowSaveFileDialog;
}
/// <summary>
/// Result from unsaved changes prompt.
/// </summary>
public enum UnsavedChangesResult
{
Save,
DontSave,
Cancel
}
@@ -0,0 +1,76 @@
using System.Windows.Input;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// A command implementation that delegates to action methods.
/// </summary>
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;
private EventHandler? _canExecuteChanged;
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
remove => _canExecuteChanged -= value;
}
/// <summary>
/// Creates a new RelayCommand that can always execute.
/// </summary>
/// <param name="execute">The action to execute.</param>
public RelayCommand(Action<object?> execute)
: this(execute, null)
{
}
/// <summary>
/// Creates a new RelayCommand with a CanExecute predicate.
/// </summary>
/// <param name="execute">The action to execute.</param>
/// <param name="canExecute">The predicate to determine if the command can execute.</param>
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
/// <summary>
/// Creates a new RelayCommand from a parameterless action.
/// </summary>
/// <param name="execute">The action to execute.</param>
public RelayCommand(Action execute)
: this(_ => execute(), null)
{
}
/// <summary>
/// Creates a new RelayCommand from a parameterless action with a CanExecute predicate.
/// </summary>
/// <param name="execute">The action to execute.</param>
/// <param name="canExecute">The predicate to determine if the command can execute.</param>
public RelayCommand(Action execute, Func<bool>? canExecute)
: this(_ => execute(), canExecute != null ? _ => canExecute() : null)
{
}
public bool CanExecute(object? parameter)
{
return _canExecute == null || _canExecute(parameter);
}
public void Execute(object? parameter)
{
_execute(parameter);
}
/// <summary>
/// Raises the CanExecuteChanged event.
/// </summary>
public void RaiseCanExecuteChanged()
{
_canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
@@ -0,0 +1,87 @@
using System.Windows.Input;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// View model for an individual secret item with show/hide toggle.
/// </summary>
public class SecretItemViewModel : ViewModelBase
{
private readonly string _actualValue;
private bool _isValueVisible;
private const string MaskedValue = "********";
public SecretItemViewModel(string key, string value)
{
Key = key;
_actualValue = value;
ToggleVisibilityCommand = new RelayCommand(ToggleVisibility);
CopyToClipboardCommand = new RelayCommand(CopyToClipboard);
}
/// <summary>
/// Gets the secret key.
/// </summary>
public string Key { get; }
/// <summary>
/// Gets the displayed value (masked or actual based on visibility).
/// </summary>
public string DisplayValue => _isValueVisible ? _actualValue : MaskedValue;
/// <summary>
/// Gets the actual unmasked value.
/// </summary>
public string ActualValue => _actualValue;
/// <summary>
/// Gets or sets whether the value is visible.
/// </summary>
public bool IsValueVisible
{
get => _isValueVisible;
set
{
if (SetProperty(ref _isValueVisible, value))
{
OnPropertyChanged(nameof(DisplayValue));
}
}
}
/// <summary>
/// Command to toggle visibility of the secret value.
/// </summary>
public ICommand ToggleVisibilityCommand { get; }
/// <summary>
/// Command to copy the secret value to clipboard.
/// </summary>
public ICommand CopyToClipboardCommand { get; }
/// <summary>
/// Event raised when clipboard copy is requested.
/// The view subscribes to this to perform the actual clipboard operation.
/// </summary>
public event Func<string, Task>? OnCopyToClipboard;
private void ToggleVisibility()
{
IsValueVisible = !IsValueVisible;
}
private async void CopyToClipboard()
{
try
{
if (OnCopyToClipboard != null)
{
await OnCopyToClipboard(_actualValue);
}
}
catch
{
// Clipboard operations can fail in some scenarios
}
}
}
@@ -0,0 +1,39 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// Base class for all view models providing INotifyPropertyChanged implementation.
/// </summary>
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// Raises the PropertyChanged event for the specified property.
/// </summary>
/// <param name="propertyName">The name of the property that changed.</param>
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// Sets a property value and raises PropertyChanged if the value changed.
/// </summary>
/// <typeparam name="T">The type of the property.</typeparam>
/// <param name="field">Reference to the backing field.</param>
/// <param name="value">The new value.</param>
/// <param name="propertyName">The name of the property.</param>
/// <returns>True if the value changed, false otherwise.</returns>
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
@@ -0,0 +1,116 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:JdeScoping.SecureStoreManager.ViewModels"
x:Class="JdeScoping.SecureStoreManager.Views.MainWindow"
Title="{Binding WindowTitle}"
Height="500" Width="800"
MinHeight="400" MinWidth="600"
WindowStartupLocation="CenterScreen">
<Window.DataContext>
<vm:MainWindowViewModel />
</Window.DataContext>
<Window.KeyBindings>
<KeyBinding Gesture="Ctrl+N" Command="{Binding NewStoreCommand}" />
<KeyBinding Gesture="Ctrl+O" Command="{Binding OpenStoreCommand}" />
<KeyBinding Gesture="Ctrl+S" Command="{Binding SaveCommand}" />
<KeyBinding Gesture="Ctrl+W" Command="{Binding CloseStoreCommand}" />
<KeyBinding Gesture="Delete" Command="{Binding DeleteSecretCommand}" />
</Window.KeyBindings>
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- Menu Bar -->
<Menu Grid.Row="0">
<MenuItem Header="_File">
<MenuItem Header="_New Store..." Command="{Binding NewStoreCommand}" InputGesture="Ctrl+N" />
<MenuItem Header="_Open Store..." Command="{Binding OpenStoreCommand}" InputGesture="Ctrl+O" />
<Separator />
<MenuItem Header="_Save" Command="{Binding SaveCommand}" InputGesture="Ctrl+S" />
<Separator />
<MenuItem Header="_Close Store" Command="{Binding CloseStoreCommand}" InputGesture="Ctrl+W" />
<Separator />
<MenuItem Header="E_xit" Command="{Binding ExitCommand}" InputGesture="Alt+F4" />
</MenuItem>
<MenuItem Header="_Secrets">
<MenuItem Header="_Add Secret..." Command="{Binding AddSecretCommand}" />
<MenuItem Header="_Edit Secret..." Command="{Binding EditSecretCommand}" />
<Separator />
<MenuItem Header="_Delete Secret" Command="{Binding DeleteSecretCommand}" InputGesture="Delete" />
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem Header="_Generate Key File..." Command="{Binding GenerateKeyFileCommand}" />
<MenuItem Header="_Export Current Key..." Command="{Binding ExportKeyCommand}" />
</MenuItem>
</Menu>
<!-- Toolbar -->
<Border Grid.Row="1" BorderBrush="LightGray" BorderThickness="0,0,0,1" Padding="5">
<StackPanel Orientation="Horizontal" Spacing="5">
<Button Content="New" Command="{Binding NewStoreCommand}" ToolTip.Tip="Create new store (Ctrl+N)" Padding="8,4" />
<Button Content="Open" Command="{Binding OpenStoreCommand}" ToolTip.Tip="Open existing store (Ctrl+O)" Padding="8,4" />
<Button Content="Save" Command="{Binding SaveCommand}" ToolTip.Tip="Save changes (Ctrl+S)" Padding="8,4" />
<Rectangle Width="1" Fill="LightGray" Margin="5,0" />
<Button Content="Add" Command="{Binding AddSecretCommand}" ToolTip.Tip="Add new secret" Padding="8,4" />
<Button Content="Edit" Command="{Binding EditSecretCommand}" ToolTip.Tip="Edit selected secret" Padding="8,4" />
<Button Content="Delete" Command="{Binding DeleteSecretCommand}" ToolTip.Tip="Delete selected secret (Delete)" Padding="8,4" />
</StackPanel>
</Border>
<!-- Main Content -->
<Grid Grid.Row="2" Margin="10">
<!-- Empty State -->
<TextBlock Text="No store open. Use File > New Store or File > Open Store to begin."
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="14" Foreground="Gray"
IsVisible="{Binding !IsStoreOpen}" />
<!-- Secrets Grid -->
<DataGrid ItemsSource="{Binding Secrets}"
SelectedItem="{Binding SelectedSecret}"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Single"
CanUserReorderColumns="True"
CanUserResizeColumns="True"
CanUserSortColumns="True"
GridLinesVisibility="Horizontal"
IsVisible="{Binding IsStoreOpen}"
x:Name="SecretsDataGrid"
DoubleTapped="DataGrid_DoubleTapped">
<DataGrid.Columns>
<DataGridTextColumn Header="Key" Binding="{Binding Key}" Width="200" />
<DataGridTextColumn Header="Value" Binding="{Binding DisplayValue}" Width="*" />
<DataGridTemplateColumn Header="Actions" Width="150">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5">
<Button Content="{Binding IsValueVisible, Converter={StaticResource BoolToVisibilityIcon}}"
Command="{Binding ToggleVisibilityCommand}"
ToolTip.Tip="Show/Hide value"
Width="50" Height="24"
FontSize="11" />
<Button Content="Copy"
Command="{Binding CopyToClipboardCommand}"
ToolTip.Tip="Copy value to clipboard"
Width="50" Height="24"
FontSize="11" />
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
<!-- Status Bar -->
<Border Grid.Row="3" Background="#F0F0F0" BorderBrush="LightGray" BorderThickness="0,1,0,0" Padding="10,5">
<DockPanel>
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right">
<TextBlock Text="Secrets: " />
<TextBlock Text="{Binding Secrets.Count}" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" />
</DockPanel>
</Border>
</Grid>
</Window>
@@ -0,0 +1,187 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using JdeScoping.SecureStoreManager.ViewModels;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
namespace JdeScoping.SecureStoreManager.Views;
public partial class MainWindow : Window
{
private MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!;
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
}
private void MainWindow_Loaded(object? sender, RoutedEventArgs e)
{
// Subscribe to dialog request events
ViewModel.OnRequestNewStoreDialog += ShowNewStoreDialog;
ViewModel.OnRequestOpenStoreDialog += ShowOpenStoreDialog;
ViewModel.OnRequestAddSecretDialog += ShowAddSecretDialog;
ViewModel.OnRequestEditSecretDialog += ShowEditSecretDialog;
ViewModel.OnRequestClose += () => Close();
// Subscribe to async dialog events
ViewModel.OnShowError += ShowErrorAsync;
ViewModel.OnShowInfo += ShowInfoAsync;
ViewModel.OnShowUnsavedChangesPrompt += ShowUnsavedChangesPromptAsync;
ViewModel.OnShowDeleteConfirmation += ShowDeleteConfirmationAsync;
ViewModel.OnShowSaveFileDialog += ShowSaveFileDialogAsync;
// Subscribe to clipboard events for secrets
ViewModel.Secrets.CollectionChanged += (s, e) =>
{
if (e.NewItems != null)
{
foreach (SecretItemViewModel secret in e.NewItems)
{
secret.OnCopyToClipboard += CopyToClipboardAsync;
}
}
};
}
private async void MainWindow_Closing(object? sender, WindowClosingEventArgs e)
{
e.Cancel = true;
if (await ViewModel.PromptForUnsavedChangesAsync())
{
e.Cancel = false;
}
}
private void DataGrid_DoubleTapped(object? sender, TappedEventArgs e)
{
if (ViewModel.SelectedSecret != null)
{
ViewModel.EditSecretCommand.Execute(null);
}
}
private async void ShowNewStoreDialog()
{
var dialog = new NewStoreDialog();
var result = await dialog.ShowDialog<bool?>(this);
if (result == true)
{
var vm = dialog.ViewModel;
await ViewModel.CreateNewStoreAsync(
vm.StorePath,
vm.UseKeyFile ? vm.KeyFilePath : null,
vm.UsePassword ? vm.Password : null);
}
}
private async void ShowOpenStoreDialog()
{
var dialog = new OpenStoreDialog();
var result = await dialog.ShowDialog<bool?>(this);
if (result == true)
{
var vm = dialog.ViewModel;
await ViewModel.OpenExistingStoreAsync(
vm.StorePath,
vm.UseKeyFile ? vm.KeyFilePath : null,
vm.UsePassword ? vm.Password : null);
}
}
private async void ShowAddSecretDialog()
{
var dialog = new SecretEditDialog();
var result = await dialog.ShowDialog<bool?>(this);
if (result == true)
{
var vm = dialog.ViewModel;
await ViewModel.SaveSecretAsync(vm.Key, vm.Value, isNew: true);
}
}
private async void ShowEditSecretDialog(string key, string value)
{
var dialog = new SecretEditDialog(key, value);
var result = await dialog.ShowDialog<bool?>(this);
if (result == true)
{
var vm = dialog.ViewModel;
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);
}
}
}
@@ -0,0 +1,100 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:JdeScoping.SecureStoreManager.ViewModels"
x:Class="JdeScoping.SecureStoreManager.Views.NewStoreDialog"
Title="Create New Store"
Height="400" Width="500"
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<Window.DataContext>
<vm:NewStoreDialogViewModel />
</Window.DataContext>
<Grid Margin="15" RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- Store Path -->
<Border Grid.Row="0" BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="Store Location" FontWeight="SemiBold" Margin="0,0,0,10" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
Text="{Binding StorePath, Mode=TwoWay}"
Margin="0,0,5,0" />
<Button Grid.Column="1"
Content="Browse..."
Command="{Binding BrowseStorePathCommand}"
Padding="10,5" />
</Grid>
</StackPanel>
</Border>
<!-- Encryption Method -->
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="Encryption Method" FontWeight="SemiBold" Margin="0,0,0,10" />
<RadioButton Content="Use Key File (recommended for production)"
IsChecked="{Binding UseKeyFile, Mode=TwoWay}"
Margin="0,5" />
<RadioButton Content="Use Password"
IsChecked="{Binding UsePassword, Mode=TwoWay}"
Margin="0,5" />
</StackPanel>
</Border>
<!-- Key File Settings -->
<Border Grid.Row="2"
BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10"
IsVisible="{Binding UseKeyFile}">
<StackPanel>
<TextBlock Text="Key File" FontWeight="SemiBold" Margin="0,0,0,10" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
Text="{Binding KeyFilePath, Mode=TwoWay}"
Margin="0,0,5,0" />
<Button Grid.Column="1"
Content="Browse..."
Command="{Binding BrowseKeyFilePathCommand}"
Padding="10,5" />
</Grid>
</StackPanel>
</Border>
<!-- Password Settings -->
<Border Grid.Row="2"
BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10"
IsVisible="{Binding UsePassword}">
<StackPanel>
<TextBlock Text="Password" FontWeight="SemiBold" Margin="0,0,0,10" />
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Password:" VerticalAlignment="Center" Margin="0,5" />
<TextBox Grid.Row="0" Grid.Column="1"
x:Name="PasswordBox"
PasswordChar="*"
Text="{Binding Password, Mode=TwoWay}"
Margin="0,5" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Confirm:" VerticalAlignment="Center" Margin="0,5" />
<TextBox Grid.Row="1" Grid.Column="1"
x:Name="ConfirmPasswordBox"
PasswordChar="*"
Text="{Binding ConfirmPassword, Mode=TwoWay}"
Margin="0,5" />
</Grid>
</StackPanel>
</Border>
<!-- Validation Error -->
<TextBlock Grid.Row="3"
Text="{Binding ValidationError}"
Foreground="Red"
FontSize="11"
Margin="0,5,0,0"
IsVisible="{Binding ValidationError, Converter={StaticResource StringToBool}}"
VerticalAlignment="Top" />
<!-- Buttons -->
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Content="Create" Click="CreateButton_Click" MinWidth="80" Padding="10,5" />
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,62 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using JdeScoping.SecureStoreManager.ViewModels;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
namespace JdeScoping.SecureStoreManager.Views;
public partial class NewStoreDialog : Window
{
public NewStoreDialogViewModel ViewModel => (NewStoreDialogViewModel)DataContext!;
public NewStoreDialog()
{
InitializeComponent();
Loaded += NewStoreDialog_Loaded;
}
private void NewStoreDialog_Loaded(object? sender, RoutedEventArgs e)
{
ViewModel.OnShowSaveFileDialog += ShowSaveFileDialogAsync;
}
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 void CreateButton_Click(object? sender, RoutedEventArgs e)
{
if (!ViewModel.IsValid)
{
var box = MessageBoxManager
.GetMessageBoxStandard(
"Validation Error",
ViewModel.ValidationError ?? "Please fill in all required fields.",
ButtonEnum.Ok,
MsBox.Avalonia.Enums.Icon.Warning);
await box.ShowWindowDialogAsync(this);
return;
}
Close(true);
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
Close(false);
}
}
@@ -0,0 +1,94 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:JdeScoping.SecureStoreManager.ViewModels"
x:Class="JdeScoping.SecureStoreManager.Views.OpenStoreDialog"
Title="Open Store"
Height="370" Width="500"
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<Window.DataContext>
<vm:OpenStoreDialogViewModel />
</Window.DataContext>
<Grid Margin="15" RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- Store Path -->
<Border Grid.Row="0" BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="Store File" FontWeight="SemiBold" Margin="0,0,0,10" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
Text="{Binding StorePath, Mode=TwoWay}"
Margin="0,0,5,0" />
<Button Grid.Column="1"
Content="Browse..."
Command="{Binding BrowseStorePathCommand}"
Padding="10,5" />
</Grid>
</StackPanel>
</Border>
<!-- Decryption Method -->
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="Decryption Method" FontWeight="SemiBold" Margin="0,0,0,10" />
<RadioButton Content="Use Key File"
IsChecked="{Binding UseKeyFile, Mode=TwoWay}"
Margin="0,5" />
<RadioButton Content="Use Password"
IsChecked="{Binding UsePassword, Mode=TwoWay}"
Margin="0,5" />
</StackPanel>
</Border>
<!-- Key File Settings -->
<Border Grid.Row="2"
BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10"
IsVisible="{Binding UseKeyFile}">
<StackPanel>
<TextBlock Text="Key File" FontWeight="SemiBold" Margin="0,0,0,10" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
Text="{Binding KeyFilePath, Mode=TwoWay}"
Margin="0,0,5,0" />
<Button Grid.Column="1"
Content="Browse..."
Command="{Binding BrowseKeyFilePathCommand}"
Padding="10,5" />
</Grid>
</StackPanel>
</Border>
<!-- Password Settings -->
<Border Grid.Row="2"
BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10"
IsVisible="{Binding UsePassword}">
<StackPanel>
<TextBlock Text="Password" FontWeight="SemiBold" Margin="0,0,0,10" />
<Grid ColumnDefinitions="100,*">
<TextBlock Text="Password:" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
x:Name="PasswordBox"
PasswordChar="*"
Text="{Binding Password, Mode=TwoWay}"
Margin="0,5" />
</Grid>
</StackPanel>
</Border>
<!-- Validation Error -->
<TextBlock Grid.Row="3"
Text="{Binding ValidationError}"
Foreground="Red"
FontSize="11"
Margin="0,5,0,0"
IsVisible="{Binding ValidationError, Converter={StaticResource StringToBool}}"
VerticalAlignment="Top" />
<!-- Buttons -->
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Content="Open" Click="OpenButton_Click" MinWidth="80" Padding="10,5" />
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,62 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using JdeScoping.SecureStoreManager.ViewModels;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
namespace JdeScoping.SecureStoreManager.Views;
public partial class OpenStoreDialog : Window
{
public OpenStoreDialogViewModel ViewModel => (OpenStoreDialogViewModel)DataContext!;
public OpenStoreDialog()
{
InitializeComponent();
Loaded += OpenStoreDialog_Loaded;
}
private void OpenStoreDialog_Loaded(object? sender, RoutedEventArgs e)
{
ViewModel.OnShowOpenFileDialog += ShowOpenFileDialogAsync;
}
private async Task<string?> ShowOpenFileDialogAsync(string title, string fileTypeName, string pattern)
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = title,
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } }
}
});
return files.Count > 0 ? files[0].Path.LocalPath : null;
}
private async void OpenButton_Click(object? sender, RoutedEventArgs e)
{
if (!ViewModel.IsValid)
{
var box = MessageBoxManager
.GetMessageBoxStandard(
"Validation Error",
ViewModel.ValidationError ?? "Please fill in all required fields.",
ButtonEnum.Ok,
MsBox.Avalonia.Enums.Icon.Warning);
await box.ShowWindowDialogAsync(this);
return;
}
Close(true);
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
Close(false);
}
}
@@ -0,0 +1,50 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:JdeScoping.SecureStoreManager.ViewModels"
x:Class="JdeScoping.SecureStoreManager.Views.SecretEditDialog"
Title="{Binding DialogTitle}"
Height="280" Width="450"
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<Window.DataContext>
<vm:SecretEditDialogViewModel />
</Window.DataContext>
<Grid Margin="15" RowDefinitions="Auto,Auto,*,Auto">
<!-- Key -->
<Grid Grid.Row="0" ColumnDefinitions="80,*" Margin="0,0,0,10">
<TextBlock Grid.Column="0" Text="Key:" VerticalAlignment="Center" Margin="0,5,10,5" />
<TextBox Grid.Column="1"
Text="{Binding Key, Mode=TwoWay}"
IsEnabled="{Binding IsKeyEditable}"
Margin="0,5" />
</Grid>
<!-- Value -->
<Grid Grid.Row="1" ColumnDefinitions="80,*" Margin="0,0,0,10">
<TextBlock Grid.Column="0" Text="Value:" VerticalAlignment="Top" Margin="0,8,10,5" />
<TextBox Grid.Column="1"
Text="{Binding Value, Mode=TwoWay}"
TextWrapping="Wrap"
AcceptsReturn="True"
Height="80"
Margin="0,5" />
</Grid>
<!-- Validation Error -->
<TextBlock Grid.Row="2"
Text="{Binding ValidationError}"
Foreground="Red"
FontSize="11"
Margin="0,5,0,0"
IsVisible="{Binding ValidationError, Converter={StaticResource StringToBool}}"
VerticalAlignment="Top" />
<!-- Buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Content="Save" Click="SaveButton_Click" MinWidth="80" Padding="10,5" />
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,44 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using JdeScoping.SecureStoreManager.ViewModels;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
namespace JdeScoping.SecureStoreManager.Views;
public partial class SecretEditDialog : Window
{
public SecretEditDialogViewModel ViewModel => (SecretEditDialogViewModel)DataContext!;
public SecretEditDialog()
{
InitializeComponent();
}
public SecretEditDialog(string key, string value) : this()
{
DataContext = new SecretEditDialogViewModel(key, value);
}
private async void SaveButton_Click(object? sender, RoutedEventArgs e)
{
if (!ViewModel.IsValid)
{
var box = MessageBoxManager
.GetMessageBoxStandard(
"Validation Error",
ViewModel.ValidationError ?? "Please fill in all required fields.",
ButtonEnum.Ok,
MsBox.Avalonia.Enums.Icon.Warning);
await box.ShowWindowDialogAsync(this);
return;
}
Close(true);
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
Close(false);
}
}