chore: deprecate standalone SecureStoreManager utility
Move SecureStoreManager project and tests to Deprecated folder and remove from solution. SecureStore functionality is now integrated into ConfigManager.
This commit is contained in:
@@ -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,74 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using JdeScoping.SecureStoreManager.Application;
|
||||
using JdeScoping.SecureStoreManager.Services;
|
||||
using JdeScoping.SecureStoreManager.ViewModels;
|
||||
using JdeScoping.SecureStoreManager.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager;
|
||||
|
||||
public partial class App : Avalonia.Application
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the service provider instance for dependency injection.
|
||||
/// </summary>
|
||||
public static IServiceProvider Services { get; private set; } = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
{
|
||||
// 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,63 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretUseCases"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storeManager">The secure store manager.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
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>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The secret value.</param>
|
||||
public void SetSecret(string key, string value)
|
||||
{
|
||||
_logger.LogInformation("Setting secret {Key}", key);
|
||||
_storeManager.SetSecret(key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a secret by key.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key to remove.</param>
|
||||
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>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>The secret value.</returns>
|
||||
public string GetSecret(string key)
|
||||
{
|
||||
return _storeManager.GetSecret(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StoreUseCases"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storeManager">The secure store manager instance.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
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 key file authentication.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The path where the store will be created.</param>
|
||||
/// <param name="keyFilePath">The path to the key file.</param>
|
||||
public void CreateStore(string storePath, string keyFilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyFilePath))
|
||||
throw new ArgumentException("Key file path must be provided.", nameof(keyFilePath));
|
||||
|
||||
_logger.LogInformation("Creating store at {StorePath}", storePath);
|
||||
_storeManager.CreateStore(storePath, keyFilePath);
|
||||
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an existing store with key file authentication.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The path to the existing store.</param>
|
||||
/// <param name="keyFilePath">The path to the key file.</param>
|
||||
public void OpenStore(string storePath, string keyFilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyFilePath))
|
||||
throw new ArgumentException("Key file path must be provided.", nameof(keyFilePath));
|
||||
|
||||
_logger.LogInformation("Opening store at {StorePath}", storePath);
|
||||
_storeManager.OpenStore(storePath, keyFilePath);
|
||||
_logger.LogDebug("Store opened with key file");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="path">The path where the key file will be generated.</param>
|
||||
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>
|
||||
/// <param name="path">The path where the key will be exported.</param>
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Inverts a boolean value.
|
||||
/// </summary>
|
||||
public class InverseBooleanConverter : IValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a boolean value to its inverted counterpart.
|
||||
/// </summary>
|
||||
/// <param name="value">The boolean value to invert.</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>The inverted boolean value, or false if the input is not a boolean.</returns>
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool boolValue)
|
||||
{
|
||||
return !boolValue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a value back to a boolean by inverting it.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to invert.</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>The inverted boolean value, or false if the input is not a boolean.</returns>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a boolean to a visibility icon string.
|
||||
/// </summary>
|
||||
/// <param name="value">The boolean value indicating visibility.</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>"Hide" if true, "Show" if false or input is not boolean.</returns>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a value back (not implemented for visibility icons).
|
||||
/// </summary>
|
||||
/// <param name="value">The value to convert back (ignored).</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>Not implemented.</returns>
|
||||
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a value to a boolean based on null status.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check for null.</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>True if value is not null, false otherwise.</returns>
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a value back (not implemented for null checks).
|
||||
/// </summary>
|
||||
/// <param name="value">The value to convert back (ignored).</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>Not implemented.</returns>
|
||||
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a string to a boolean based on whether it's empty or null.
|
||||
/// </summary>
|
||||
/// <param name="value">The string value to check.</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>True if string is not null or whitespace, false otherwise.</returns>
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is string str)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(str);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a value back (not implemented for string checks).
|
||||
/// </summary>
|
||||
/// <param name="value">The value to convert back (ignored).</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>Not implemented.</returns>
|
||||
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<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" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.*" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager;
|
||||
|
||||
internal class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The main entry point for the application.
|
||||
/// </summary>
|
||||
/// <param name="args">Command-line arguments.</param>
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the Avalonia application configuration.
|
||||
/// </summary>
|
||||
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,33 @@
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the clipboard text asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to set on the clipboard.</param>
|
||||
public async Task SetTextAsync(string text)
|
||||
{
|
||||
var clipboard = _getClipboard();
|
||||
if (clipboard != null)
|
||||
{
|
||||
await clipboard.SetTextAsync(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays an error dialog with the specified message and title.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message to display.</param>
|
||||
/// <param name="title">The title of the error dialog.</param>
|
||||
/// <returns>A task that completes when the dialog is dismissed.</returns>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays an informational dialog with the specified message and title.
|
||||
/// </summary>
|
||||
/// <param name="message">The information message to display.</param>
|
||||
/// <param name="title">The title of the information dialog.</param>
|
||||
/// <returns>A task that completes when the dialog is dismissed.</returns>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays a confirmation dialog with Yes/No buttons.
|
||||
/// </summary>
|
||||
/// <param name="message">The confirmation message to display.</param>
|
||||
/// <param name="title">The title of the confirmation dialog.</param>
|
||||
/// <returns>A task that completes with true if Yes was clicked; otherwise, false.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays a prompt asking the user whether to save unsaved changes.
|
||||
/// </summary>
|
||||
/// <returns>A task that completes with the user's choice: Save, DontSave, or Cancel.</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays a save file dialog allowing the user to select a file path.
|
||||
/// </summary>
|
||||
/// <param name="title">The title of the save file dialog.</param>
|
||||
/// <param name="fileTypeName">The friendly name of the file type (e.g., "Excel Files").</param>
|
||||
/// <param name="pattern">The file extension pattern (e.g., "*.xlsx").</param>
|
||||
/// <param name="defaultExtension">The default file extension (e.g., ".xlsx").</param>
|
||||
/// <returns>A task that completes with the selected file path, or null if canceled.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays an open file dialog allowing the user to select a file to open.
|
||||
/// </summary>
|
||||
/// <param name="title">The title of the open file dialog.</param>
|
||||
/// <param name="fileTypeName">The friendly name of the file type (e.g., "Excel Files").</param>
|
||||
/// <param name="pattern">The file extension pattern (e.g., "*.xlsx").</param>
|
||||
/// <returns>A task that completes with the selected file path, or null if canceled.</returns>
|
||||
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,64 @@
|
||||
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>
|
||||
/// <param name="message">The error message to display.</param>
|
||||
/// <param name="title">The dialog title.</param>
|
||||
Task ShowErrorAsync(string message, string title);
|
||||
|
||||
/// <summary>
|
||||
/// Shows an informational message dialog.
|
||||
/// </summary>
|
||||
/// <param name="message">The informational message to display.</param>
|
||||
/// <param name="title">The dialog title.</param>
|
||||
Task ShowInfoAsync(string message, string title);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a confirmation dialog with Yes/No options.
|
||||
/// </summary>
|
||||
/// <param name="message">The confirmation message to display.</param>
|
||||
/// <param name="title">The dialog title.</param>
|
||||
/// <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);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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>
|
||||
/// 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>
|
||||
/// 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,291 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeoSmart.SecureStore;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages SecureStore encrypted secret stores for the Avalonia application.
|
||||
/// </summary>
|
||||
public class SecureStoreManager : ISecureStoreManager, IDisposable
|
||||
{
|
||||
private readonly ILogger<SecureStoreManager> _logger;
|
||||
private SecretsManager? _secretsManager;
|
||||
private string? _currentStorePath;
|
||||
private readonly HashSet<string> _keys = new();
|
||||
private bool _hasUnsavedChanges;
|
||||
private bool _disposed;
|
||||
|
||||
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>
|
||||
/// <param name="logger">Logger instance for diagnostic output.</param>
|
||||
public SecureStoreManager(ILogger<SecureStoreManager> logger)
|
||||
{
|
||||
_logger = logger ?? NullLogger<SecureStoreManager>.Instance;
|
||||
}
|
||||
|
||||
/// <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();
|
||||
_logger.LogInformation("Creating new store at {StorePath}", storePath);
|
||||
CloseStoreInternal();
|
||||
|
||||
EnsureDirectory(storePath);
|
||||
EnsureDirectory(keyFilePath);
|
||||
|
||||
_secretsManager = SecretsManager.CreateStore();
|
||||
_secretsManager.GenerateKey();
|
||||
_secretsManager.ExportKey(keyFilePath);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
_keys.Clear();
|
||||
_hasUnsavedChanges = true;
|
||||
|
||||
Save();
|
||||
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OpenStore(string storePath, string keyFilePath)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_logger.LogInformation("Opening store at {StorePath}", storePath);
|
||||
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;
|
||||
_logger.LogDebug("Store opened with key file, contains {KeyCount} keys", _keys.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CloseStore()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_logger.LogInformation("Closing store");
|
||||
CloseStoreInternal();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null || _currentStorePath == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
_logger.LogInformation("Saving store changes");
|
||||
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.IsNullOrWhiteSpace(key))
|
||||
throw new ArgumentException("Key cannot be null or whitespace.", nameof(key));
|
||||
|
||||
if (ReservedKeys.Contains(key))
|
||||
{
|
||||
_logger.LogWarning("Attempted to access reserved key {Key}", key);
|
||||
throw new ArgumentException($"The key '{key}' is reserved for internal use.", nameof(key));
|
||||
}
|
||||
|
||||
_logger.LogDebug("Setting secret for key {Key}", key);
|
||||
_secretsManager.Set(key, value ?? string.Empty);
|
||||
_keys.Add(key);
|
||||
_hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveSecret(string key)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new ArgumentException("Key cannot be null or whitespace.", nameof(key));
|
||||
|
||||
if (ReservedKeys.Contains(key))
|
||||
{
|
||||
_logger.LogWarning("Attempted to access reserved key {Key}", key);
|
||||
throw new ArgumentException($"The key '{key}' is reserved for internal use.", nameof(key));
|
||||
}
|
||||
|
||||
if (!_keys.Remove(key))
|
||||
throw new KeyNotFoundException($"Secret '{key}' not found.");
|
||||
|
||||
_logger.LogInformation("Removing secret for key {Key}", key);
|
||||
_secretsManager.Delete(key);
|
||||
_hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the resources used by the <see cref="SecureStoreManager"/>.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_secretsManager?.Dispose();
|
||||
_secretsManager = null;
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the result of CanExecute() may have changed.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the command can be executed in its current state.
|
||||
/// </summary>
|
||||
/// <param name="parameter">The command parameter (unused).</param>
|
||||
public bool CanExecute(object? parameter)
|
||||
{
|
||||
return !_isExecuting && (_canExecute?.Invoke() ?? true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the async command if it can execute.
|
||||
/// </summary>
|
||||
/// <param name="parameter">The command parameter (unused).</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Windows.Input;
|
||||
using JdeScoping.SecureStoreManager.Constants;
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NewStoreDialogViewModel"/> class.
|
||||
/// </summary>
|
||||
public NewStoreDialogViewModel()
|
||||
{
|
||||
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
|
||||
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the store file to create.
|
||||
/// </summary>
|
||||
public string StorePath
|
||||
{
|
||||
get => _storePath;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _storePath, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the key file for encryption.
|
||||
/// </summary>
|
||||
public string KeyFilePath
|
||||
{
|
||||
get => _keyFilePath;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _keyFilePath, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyValidationChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsValid));
|
||||
OnPropertyChanged(nameof(ValidationError));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to browse for store path location.
|
||||
/// </summary>
|
||||
public ICommand BrowseStorePathCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to browse for key file path location.
|
||||
/// </summary>
|
||||
public ICommand BrowseKeyFilePathCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dialog input is valid.
|
||||
/// </summary>
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(StorePath) && !string.IsNullOrWhiteSpace(KeyFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the validation error message, or null if valid.
|
||||
/// </summary>
|
||||
public string? ValidationError
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(StorePath))
|
||||
return DialogStrings.StorePathRequired;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyFilePath))
|
||||
return DialogStrings.KeyFilePathRequired;
|
||||
|
||||
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(
|
||||
DialogStrings.ChooseStoreLocation,
|
||||
FileExtensions.StoreTypeName,
|
||||
FileExtensions.StorePattern,
|
||||
FileExtensions.StoreExtension);
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
StorePath = path;
|
||||
}
|
||||
}
|
||||
|
||||
private async void BrowseKeyFilePath()
|
||||
{
|
||||
if (OnShowSaveFileDialog == null)
|
||||
return;
|
||||
|
||||
var path = await OnShowSaveFileDialog(
|
||||
DialogStrings.ChooseKeyFileLocation,
|
||||
FileExtensions.KeyTypeName,
|
||||
FileExtensions.KeyPattern,
|
||||
FileExtensions.KeyExtension);
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OpenStoreDialogViewModel"/> class.
|
||||
/// </summary>
|
||||
public OpenStoreDialogViewModel()
|
||||
{
|
||||
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
|
||||
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the store file to open.
|
||||
/// </summary>
|
||||
public string StorePath
|
||||
{
|
||||
get => _storePath;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _storePath, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the key file for decryption.
|
||||
/// </summary>
|
||||
public string KeyFilePath
|
||||
{
|
||||
get => _keyFilePath;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _keyFilePath, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyValidationChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsValid));
|
||||
OnPropertyChanged(nameof(ValidationError));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to browse for store path location.
|
||||
/// </summary>
|
||||
public ICommand BrowseStorePathCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to browse for key file path location.
|
||||
/// </summary>
|
||||
public ICommand BrowseKeyFilePathCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dialog input is valid.
|
||||
/// </summary>
|
||||
public bool IsValid
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(StorePath))
|
||||
return false;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(KeyFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the validation error message, or null if valid.
|
||||
/// </summary>
|
||||
public string? ValidationError
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(StorePath))
|
||||
return DialogStrings.StorePathRequired;
|
||||
|
||||
if (!System.IO.File.Exists(StorePath))
|
||||
return DialogStrings.StoreFileNotFound;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyFilePath))
|
||||
return DialogStrings.KeyFilePathRequired;
|
||||
|
||||
if (!System.IO.File.Exists(KeyFilePath))
|
||||
return DialogStrings.KeyFileNotFound;
|
||||
|
||||
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(
|
||||
DialogStrings.SelectStoreFile,
|
||||
FileExtensions.StoreTypeName,
|
||||
FileExtensions.StorePattern);
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
StorePath = path;
|
||||
}
|
||||
}
|
||||
|
||||
private async void BrowseKeyFilePath()
|
||||
{
|
||||
if (OnShowOpenFileDialog == null)
|
||||
return;
|
||||
|
||||
var path = await OnShowOpenFileDialog(
|
||||
DialogStrings.SelectKeyFile,
|
||||
FileExtensions.KeyTypeName,
|
||||
FileExtensions.KeyPattern);
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretEditDialogViewModel"/> class.
|
||||
/// </summary>
|
||||
public SecretEditDialogViewModel()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretEditDialogViewModel"/> class with a key and value for editing.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The secret value.</param>
|
||||
public SecretEditDialogViewModel(string key, string value)
|
||||
{
|
||||
_key = key;
|
||||
_value = value;
|
||||
_isNewSecret = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the secret key.
|
||||
/// </summary>
|
||||
public string Key
|
||||
{
|
||||
get => _key;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _key, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the secret value.
|
||||
/// </summary>
|
||||
public string Value
|
||||
{
|
||||
get => _value;
|
||||
set => SetProperty(ref _value, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this is a new secret being added.
|
||||
/// </summary>
|
||||
public bool IsNewSecret
|
||||
{
|
||||
get => _isNewSecret;
|
||||
set => SetProperty(ref _isNewSecret, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the key field is editable.
|
||||
/// </summary>
|
||||
public bool IsKeyEditable => _isNewSecret;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dialog title based on whether this is a new secret or edit.
|
||||
/// </summary>
|
||||
public string DialogTitle => _isNewSecret ? "Add Secret" : "Edit Secret";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dialog input is valid.
|
||||
/// </summary>
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(Key);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the validation error message, or null if valid.
|
||||
/// </summary>
|
||||
public string? ValidationError
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Key))
|
||||
return DialogStrings.KeyRequired;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyValidationChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsValid));
|
||||
OnPropertyChanged(nameof(ValidationError));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
using JdeScoping.SecureStoreManager.Constants;
|
||||
using JdeScoping.SecureStoreManager.Services;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Main window view model containing all application logic.
|
||||
/// </summary>
|
||||
public class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ISecureStoreManager _storeManager;
|
||||
private readonly IDialogService _dialogService;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private SecretItemViewModel? _selectedSecret;
|
||||
private string _statusMessage = "Ready";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the MainWindowViewModel.
|
||||
/// </summary>
|
||||
/// <param name="storeManager">The secure store manager service.</param>
|
||||
/// <param name="dialogService">The dialog service for user interactions.</param>
|
||||
/// <param name="clipboardService">The clipboard service for copying secrets.</param>
|
||||
public MainWindowViewModel(
|
||||
ISecureStoreManager storeManager,
|
||||
IDialogService dialogService,
|
||||
IClipboardService clipboardService)
|
||||
{
|
||||
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
|
||||
_dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService));
|
||||
_clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService));
|
||||
Secrets = new ObservableCollection<SecretItemViewModel>();
|
||||
|
||||
// File commands (async)
|
||||
NewStoreCommand = new AsyncRelayCommand(ExecuteNewStoreAsync);
|
||||
OpenStoreCommand = new AsyncRelayCommand(ExecuteOpenStoreAsync);
|
||||
SaveCommand = new AsyncRelayCommand(ExecuteSaveAsync, CanSave);
|
||||
CloseStoreCommand = new AsyncRelayCommand(ExecuteCloseStoreAsync, () => _storeManager.IsStoreOpen);
|
||||
ExitCommand = new AsyncRelayCommand(ExecuteExitAsync);
|
||||
|
||||
// Secret commands
|
||||
AddSecretCommand = new RelayCommand(ExecuteAddSecret, () => _storeManager.IsStoreOpen);
|
||||
EditSecretCommand = new RelayCommand(ExecuteEditSecret, CanEditOrDeleteSecret);
|
||||
DeleteSecretCommand = new AsyncRelayCommand(ExecuteDeleteSecretAsync, CanEditOrDeleteSecret);
|
||||
|
||||
// Tools commands (async)
|
||||
GenerateKeyFileCommand = new AsyncRelayCommand(ExecuteGenerateKeyFileAsync);
|
||||
ExportKeyCommand = new AsyncRelayCommand(ExecuteExportKeyAsync, () => _storeManager.IsStoreOpen);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of secrets in the current store.
|
||||
/// </summary>
|
||||
public ObservableCollection<SecretItemViewModel> Secrets { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the currently selected secret.
|
||||
/// </summary>
|
||||
public SecretItemViewModel? SelectedSecret
|
||||
{
|
||||
get => _selectedSecret;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedSecret, value))
|
||||
{
|
||||
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current status message.
|
||||
/// </summary>
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set => SetProperty(ref _statusMessage, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the window title including the current store path.
|
||||
/// </summary>
|
||||
public string WindowTitle
|
||||
{
|
||||
get
|
||||
{
|
||||
var title = "SecureStore Manager";
|
||||
if (_storeManager.IsStoreOpen)
|
||||
{
|
||||
title += $" - {_storeManager.CurrentStorePath}";
|
||||
if (_storeManager.HasUnsavedChanges)
|
||||
title += " *";
|
||||
}
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a store is currently open.
|
||||
/// </summary>
|
||||
public bool IsStoreOpen => _storeManager.IsStoreOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are unsaved changes.
|
||||
/// </summary>
|
||||
public bool HasUnsavedChanges => _storeManager.HasUnsavedChanges;
|
||||
|
||||
// File Commands
|
||||
/// <summary>
|
||||
/// Gets the command to create a new store.
|
||||
/// </summary>
|
||||
public ICommand NewStoreCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to open an existing store.
|
||||
/// </summary>
|
||||
public ICommand OpenStoreCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to save the current store.
|
||||
/// </summary>
|
||||
public ICommand SaveCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to close the current store.
|
||||
/// </summary>
|
||||
public ICommand CloseStoreCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to exit the application.
|
||||
/// </summary>
|
||||
public ICommand ExitCommand { get; }
|
||||
|
||||
// Secret Commands
|
||||
/// <summary>
|
||||
/// Gets the command to add a new secret.
|
||||
/// </summary>
|
||||
public ICommand AddSecretCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to edit the selected secret.
|
||||
/// </summary>
|
||||
public ICommand EditSecretCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to delete the selected secret.
|
||||
/// </summary>
|
||||
public ICommand DeleteSecretCommand { get; }
|
||||
|
||||
// Tools Commands
|
||||
/// <summary>
|
||||
/// Gets the command to generate a new key file.
|
||||
/// </summary>
|
||||
public ICommand GenerateKeyFileCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to export the store's key.
|
||||
/// </summary>
|
||||
public ICommand ExportKeyCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new store with a key file. Called by the dialog.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The path where the store file will be created.</param>
|
||||
/// <param name="keyFilePath">The path to a key file for encryption.</param>
|
||||
public async Task CreateNewStoreAsync(string storePath, string keyFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyFilePath))
|
||||
{
|
||||
throw new ArgumentException("Key file path must be provided.");
|
||||
}
|
||||
|
||||
_storeManager.CreateStore(storePath, keyFilePath);
|
||||
StatusMessage = $"Created store with key file: {keyFilePath}";
|
||||
|
||||
RefreshSecrets();
|
||||
NotifyStoreChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error creating store: {ex.Message}";
|
||||
await _dialogService.ShowErrorAsync(
|
||||
string.Format(DialogStrings.FailedToCreateStoreFormat, ex.Message),
|
||||
DialogStrings.ErrorTitle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an existing store with a key file. Called by the dialog.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The path to the store file to open.</param>
|
||||
/// <param name="keyFilePath">The path to a key file for decryption.</param>
|
||||
public async Task OpenExistingStoreAsync(string storePath, string keyFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyFilePath))
|
||||
{
|
||||
throw new ArgumentException("Key file path must be provided.");
|
||||
}
|
||||
|
||||
_storeManager.OpenStore(storePath, keyFilePath);
|
||||
StatusMessage = "Opened store with key file";
|
||||
|
||||
RefreshSecrets();
|
||||
NotifyStoreChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error opening store: {ex.Message}";
|
||||
await _dialogService.ShowErrorAsync(
|
||||
string.Format(DialogStrings.FailedToOpenStoreFormat, ex.Message),
|
||||
DialogStrings.ErrorTitle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a secret. Called by the dialog.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key identifier.</param>
|
||||
/// <param name="value">The secret value to store.</param>
|
||||
/// <param name="isNew">True if this is a new secret, false if updating an existing one.</param>
|
||||
public async Task SaveSecretAsync(string key, string value, bool isNew)
|
||||
{
|
||||
try
|
||||
{
|
||||
_storeManager.SetSecret(key, value);
|
||||
RefreshSecrets();
|
||||
NotifyStoreChanged();
|
||||
StatusMessage = isNew ? $"Added secret: {key}" : $"Updated secret: {key}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving secret: {ex.Message}";
|
||||
await _dialogService.ShowErrorAsync(
|
||||
string.Format(DialogStrings.FailedToSaveSecretFormat, ex.Message),
|
||||
DialogStrings.ErrorTitle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for unsaved changes and prompts the user.
|
||||
/// </summary>
|
||||
/// <returns>True if it's safe to proceed, false if the user cancelled.</returns>
|
||||
public async Task<bool> PromptForUnsavedChangesAsync()
|
||||
{
|
||||
if (!_storeManager.HasUnsavedChanges)
|
||||
return true;
|
||||
|
||||
var result = await _dialogService.ShowUnsavedChangesPromptAsync();
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case UnsavedChangesResult.Save:
|
||||
await ExecuteSaveAsync();
|
||||
return true;
|
||||
case UnsavedChangesResult.DontSave:
|
||||
return true;
|
||||
case UnsavedChangesResult.Cancel:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteNewStoreAsync()
|
||||
{
|
||||
if (!await PromptForUnsavedChangesAsync())
|
||||
return;
|
||||
|
||||
// The view will show the NewStoreDialog
|
||||
OnRequestNewStoreDialog?.Invoke();
|
||||
}
|
||||
|
||||
private async Task ExecuteOpenStoreAsync()
|
||||
{
|
||||
if (!await PromptForUnsavedChangesAsync())
|
||||
return;
|
||||
|
||||
// The view will show the OpenStoreDialog
|
||||
OnRequestOpenStoreDialog?.Invoke();
|
||||
}
|
||||
|
||||
private async Task ExecuteSaveAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_storeManager.Save();
|
||||
NotifyStoreChanged();
|
||||
StatusMessage = "Store saved";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving: {ex.Message}";
|
||||
await _dialogService.ShowErrorAsync(
|
||||
string.Format(DialogStrings.FailedToSaveStoreFormat, ex.Message),
|
||||
DialogStrings.ErrorTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanSave() => _storeManager.IsStoreOpen && _storeManager.HasUnsavedChanges;
|
||||
|
||||
private async Task ExecuteCloseStoreAsync()
|
||||
{
|
||||
if (!await PromptForUnsavedChangesAsync())
|
||||
return;
|
||||
|
||||
_storeManager.CloseStore();
|
||||
Secrets.Clear();
|
||||
NotifyStoreChanged();
|
||||
StatusMessage = "Store closed";
|
||||
}
|
||||
|
||||
private async Task ExecuteExitAsync()
|
||||
{
|
||||
if (!await PromptForUnsavedChangesAsync())
|
||||
return;
|
||||
|
||||
OnRequestClose?.Invoke();
|
||||
}
|
||||
|
||||
private void ExecuteAddSecret()
|
||||
{
|
||||
// The view will show the SecretEditDialog
|
||||
OnRequestAddSecretDialog?.Invoke();
|
||||
}
|
||||
|
||||
private void ExecuteEditSecret()
|
||||
{
|
||||
if (SelectedSecret == null)
|
||||
return;
|
||||
|
||||
// The view will show the SecretEditDialog with existing values
|
||||
OnRequestEditSecretDialog?.Invoke(SelectedSecret.Key, SelectedSecret.ActualValue);
|
||||
}
|
||||
|
||||
private async Task ExecuteDeleteSecretAsync()
|
||||
{
|
||||
if (SelectedSecret == null)
|
||||
return;
|
||||
|
||||
var confirmMessage = string.Format(DialogStrings.ConfirmDeleteFormat, SelectedSecret.Key);
|
||||
var confirmed = await _dialogService.ShowConfirmationAsync(confirmMessage, DialogStrings.ConfirmDeleteTitle);
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var key = SelectedSecret.Key;
|
||||
_storeManager.RemoveSecret(key);
|
||||
RefreshSecrets();
|
||||
NotifyStoreChanged();
|
||||
StatusMessage = $"Deleted secret: {key}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error deleting secret: {ex.Message}";
|
||||
await _dialogService.ShowErrorAsync(
|
||||
string.Format(DialogStrings.FailedToDeleteSecretFormat, ex.Message),
|
||||
DialogStrings.ErrorTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanEditOrDeleteSecret() => _storeManager.IsStoreOpen && SelectedSecret != null;
|
||||
|
||||
private async Task ExecuteGenerateKeyFileAsync()
|
||||
{
|
||||
var filePath = await _dialogService.ShowSaveFileDialogAsync(
|
||||
DialogStrings.GenerateKeyFileTitle,
|
||||
FileExtensions.KeyTypeName,
|
||||
FileExtensions.KeyPattern,
|
||||
FileExtensions.KeyExtension);
|
||||
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_storeManager.GenerateKeyFile(filePath);
|
||||
StatusMessage = $"Generated key file: {filePath}";
|
||||
await _dialogService.ShowInfoAsync(
|
||||
string.Format(DialogStrings.KeyFileGeneratedFormat, filePath),
|
||||
DialogStrings.KeyGeneratedTitle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error generating key: {ex.Message}";
|
||||
await _dialogService.ShowErrorAsync(
|
||||
string.Format(DialogStrings.FailedToGenerateKeyFormat, ex.Message),
|
||||
DialogStrings.ErrorTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteExportKeyAsync()
|
||||
{
|
||||
var filePath = await _dialogService.ShowSaveFileDialogAsync(
|
||||
DialogStrings.ExportKeyTitle,
|
||||
FileExtensions.KeyTypeName,
|
||||
FileExtensions.KeyPattern,
|
||||
FileExtensions.KeyExtension);
|
||||
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_storeManager.ExportKey(filePath);
|
||||
StatusMessage = $"Exported key to: {filePath}";
|
||||
await _dialogService.ShowInfoAsync(
|
||||
string.Format(DialogStrings.KeyExportedFormat, filePath),
|
||||
DialogStrings.KeyExportedTitle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error exporting key: {ex.Message}";
|
||||
await _dialogService.ShowErrorAsync(
|
||||
string.Format(DialogStrings.FailedToExportKeyFormat, ex.Message),
|
||||
DialogStrings.ErrorTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshSecrets()
|
||||
{
|
||||
Secrets.Clear();
|
||||
if (!_storeManager.IsStoreOpen)
|
||||
return;
|
||||
|
||||
foreach (var key in _storeManager.GetKeys())
|
||||
{
|
||||
var value = _storeManager.GetSecret(key);
|
||||
Secrets.Add(new SecretItemViewModel(key, value, _clipboardService));
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyStoreChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStoreOpen));
|
||||
OnPropertyChanged(nameof(HasUnsavedChanges));
|
||||
OnPropertyChanged(nameof(WindowTitle));
|
||||
|
||||
// Manually raise CanExecuteChanged for all commands
|
||||
(SaveCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
(CloseStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
(AddSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
(ExportKeyCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
// Events for view to show dialogs (these require view-specific DataContext setup)
|
||||
/// <summary>
|
||||
/// Raised when a new store creation dialog should be shown.
|
||||
/// </summary>
|
||||
public event Action? OnRequestNewStoreDialog;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when an open store dialog should be shown.
|
||||
/// </summary>
|
||||
public event Action? OnRequestOpenStoreDialog;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a new secret dialog should be shown.
|
||||
/// </summary>
|
||||
public event Action? OnRequestAddSecretDialog;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when an edit secret dialog should be shown with the specified key and value.
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnRequestEditSecretDialog;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the application should close.
|
||||
/// </summary>
|
||||
public event Action? OnRequestClose;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the command's ability to execute may have changed.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the command can execute.
|
||||
/// </summary>
|
||||
/// <param name="parameter">An optional command parameter.</param>
|
||||
/// <returns>True if the command can execute, false otherwise.</returns>
|
||||
public bool CanExecute(object? parameter)
|
||||
{
|
||||
return _canExecute == null || _canExecute(parameter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the command with the specified parameter.
|
||||
/// </summary>
|
||||
/// <param name="parameter">An optional command parameter.</param>
|
||||
public void Execute(object? parameter)
|
||||
{
|
||||
_execute(parameter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the CanExecuteChanged event.
|
||||
/// </summary>
|
||||
public void RaiseCanExecuteChanged()
|
||||
{
|
||||
_canExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Windows.Input;
|
||||
using JdeScoping.SecureStoreManager.Services;
|
||||
|
||||
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 readonly IClipboardService _clipboardService;
|
||||
private bool _isValueVisible;
|
||||
private const string MaskedValue = "********";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretItemViewModel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key name.</param>
|
||||
/// <param name="value">The secret value.</param>
|
||||
/// <param name="clipboardService">The clipboard service for copy operations.</param>
|
||||
public SecretItemViewModel(string key, string value, IClipboardService clipboardService)
|
||||
{
|
||||
Key = key;
|
||||
_actualValue = value;
|
||||
_clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService));
|
||||
ToggleVisibilityCommand = new RelayCommand(ToggleVisibility);
|
||||
CopyToClipboardCommand = new RelayCommand(CopyToClipboard);
|
||||
}
|
||||
|
||||
/// <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 fails.
|
||||
/// </summary>
|
||||
public event Action<string>? OnCopyFailed;
|
||||
|
||||
private void ToggleVisibility()
|
||||
{
|
||||
IsValueVisible = !IsValueVisible;
|
||||
}
|
||||
|
||||
private async void CopyToClipboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _clipboardService.SetTextAsync(_actualValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnCopyFailed?.Invoke($"Failed to copy to clipboard: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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
|
||||
{
|
||||
/// <summary>Occurs when a property value changes.</summary>
|
||||
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,114 @@
|
||||
<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">
|
||||
<!-- DataContext is set via DI in App.axaml.cs -->
|
||||
|
||||
<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,106 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using JdeScoping.SecureStoreManager.ViewModels;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MainWindow"/> class.
|
||||
/// </summary>
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += MainWindow_Loaded;
|
||||
Closing += MainWindow_Closing;
|
||||
}
|
||||
|
||||
private void MainWindow_Loaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ViewModel == null)
|
||||
return;
|
||||
|
||||
// Subscribe to dialog request events (these open dialogs with their own DataContext)
|
||||
ViewModel.OnRequestNewStoreDialog += ShowNewStoreDialog;
|
||||
ViewModel.OnRequestOpenStoreDialog += ShowOpenStoreDialog;
|
||||
ViewModel.OnRequestAddSecretDialog += ShowAddSecretDialog;
|
||||
ViewModel.OnRequestEditSecretDialog += ShowEditSecretDialog;
|
||||
ViewModel.OnRequestClose += () => Close();
|
||||
}
|
||||
|
||||
private async void MainWindow_Closing(object? sender, WindowClosingEventArgs e)
|
||||
{
|
||||
if (ViewModel == null)
|
||||
return;
|
||||
|
||||
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()
|
||||
{
|
||||
if (ViewModel == null) return;
|
||||
|
||||
var dialog = new NewStoreDialog();
|
||||
var result = await dialog.ShowDialog<bool?>(this);
|
||||
if (result == true)
|
||||
{
|
||||
var vm = dialog.ViewModel;
|
||||
await ViewModel.CreateNewStoreAsync(vm.StorePath, vm.KeyFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private async void ShowOpenStoreDialog()
|
||||
{
|
||||
if (ViewModel == null) return;
|
||||
|
||||
var dialog = new OpenStoreDialog();
|
||||
var result = await dialog.ShowDialog<bool?>(this);
|
||||
if (result == true)
|
||||
{
|
||||
var vm = dialog.ViewModel;
|
||||
await ViewModel.OpenExistingStoreAsync(vm.StorePath, vm.KeyFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private async void ShowAddSecretDialog()
|
||||
{
|
||||
if (ViewModel == null) return;
|
||||
|
||||
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)
|
||||
{
|
||||
if (ViewModel == null) return;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<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="280" Width="500"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False">
|
||||
<!-- DataContext is set in code-behind -->
|
||||
|
||||
<Grid Margin="15" RowDefinitions="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>
|
||||
|
||||
<!-- Key File Settings -->
|
||||
<Border Grid.Row="1"
|
||||
BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Key File" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
<TextBlock Text="The key file is required to encrypt and decrypt the store."
|
||||
FontSize="11" Foreground="Gray" Margin="0,0,0,10" TextWrapping="Wrap" />
|
||||
<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>
|
||||
|
||||
<!-- 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="Create" Click="CreateButton_Click" IsEnabled="{Binding IsValid}" MinWidth="80" Padding="10,5" />
|
||||
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,70 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using JdeScoping.SecureStoreManager.Constants;
|
||||
using JdeScoping.SecureStoreManager.ViewModels;
|
||||
using MsBox.Avalonia;
|
||||
using MsBox.Avalonia.Enums;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager.Views;
|
||||
|
||||
public partial class NewStoreDialog : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the view model for this dialog.
|
||||
/// </summary>
|
||||
public NewStoreDialogViewModel ViewModel => (NewStoreDialogViewModel)DataContext!;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the NewStoreDialog.
|
||||
/// </summary>
|
||||
public NewStoreDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new NewStoreDialogViewModel();
|
||||
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(FileExtensions.AllFilesTypeName) { Patterns = new[] { FileExtensions.AllFilesPattern } }
|
||||
}
|
||||
});
|
||||
|
||||
return file?.Path.LocalPath;
|
||||
}
|
||||
|
||||
private async void CreateButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!ViewModel.IsValid)
|
||||
{
|
||||
var box = MessageBoxManager
|
||||
.GetMessageBoxStandard(
|
||||
DialogStrings.ValidationErrorTitle,
|
||||
ViewModel.ValidationError ?? DialogStrings.DefaultValidationError,
|
||||
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,63 @@
|
||||
<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="280" Width="500"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False">
|
||||
<!-- DataContext is set in code-behind -->
|
||||
|
||||
<Grid Margin="15" RowDefinitions="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>
|
||||
|
||||
<!-- Key File Settings -->
|
||||
<Border Grid.Row="1"
|
||||
BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="10" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Key File" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
<TextBlock Text="Select the key file used to encrypt this store."
|
||||
FontSize="11" Foreground="Gray" Margin="0,0,0,10" TextWrapping="Wrap" />
|
||||
<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>
|
||||
|
||||
<!-- 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="Open" Click="OpenButton_Click" IsEnabled="{Binding IsValid}" MinWidth="80" Padding="10,5" />
|
||||
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,70 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using JdeScoping.SecureStoreManager.Constants;
|
||||
using JdeScoping.SecureStoreManager.ViewModels;
|
||||
using MsBox.Avalonia;
|
||||
using MsBox.Avalonia.Enums;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager.Views;
|
||||
|
||||
public partial class OpenStoreDialog : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the view model for this dialog.
|
||||
/// </summary>
|
||||
public OpenStoreDialogViewModel ViewModel => (OpenStoreDialogViewModel)DataContext!;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OpenStoreDialog"/> class.
|
||||
/// </summary>
|
||||
public OpenStoreDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new OpenStoreDialogViewModel();
|
||||
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(FileExtensions.AllFilesTypeName) { Patterns = new[] { FileExtensions.AllFilesPattern } }
|
||||
}
|
||||
});
|
||||
|
||||
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(
|
||||
DialogStrings.ValidationErrorTitle,
|
||||
ViewModel.ValidationError ?? DialogStrings.DefaultValidationError,
|
||||
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,48 @@
|
||||
<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">
|
||||
<!-- DataContext is set in code-behind -->
|
||||
|
||||
<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" IsEnabled="{Binding IsValid}" MinWidth="80" Padding="10,5" />
|
||||
<Button Content="Cancel" Click="CancelButton_Click" MinWidth="80" Padding="10,5" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,57 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the view model for this dialog.
|
||||
/// </summary>
|
||||
public SecretEditDialogViewModel ViewModel => (SecretEditDialogViewModel)DataContext!;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretEditDialog"/> class for creating a new secret.
|
||||
/// </summary>
|
||||
public SecretEditDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new SecretEditDialogViewModel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretEditDialog"/> class for editing an existing secret.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The secret value.</param>
|
||||
public SecretEditDialog(string key, string value)
|
||||
{
|
||||
InitializeComponent();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user