diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml
index ad83944..9904d30 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml
@@ -1,8 +1,15 @@
+
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs
index 1904922..5ba5dcd 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs
@@ -1,15 +1,18 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
+using JdeScoping.ConfigManager.Application;
using JdeScoping.ConfigManager.Services;
+using JdeScoping.ConfigManager.Services.SecureStore;
using JdeScoping.ConfigManager.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager;
-public partial class App : Application
+public partial class App : Avalonia.Application
{
///
/// Gets the dependency injection service provider for the application.
@@ -60,6 +63,13 @@ public partial class App : Application
// Platform Services
services.AddSingleton(sp =>
new AvaloniaDialogService(GetMainWindow));
+ services.AddSingleton(sp =>
+ new AvaloniaClipboardService(GetClipboard));
+
+ // SecureStore Services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
// ViewModels
services.AddTransient();
@@ -69,4 +79,9 @@ public partial class App : Application
{
return (ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
}
+
+ private IClipboard? GetClipboard()
+ {
+ return GetMainWindow()?.Clipboard;
+ }
}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Application/SecretUseCases.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Application/SecretUseCases.cs
new file mode 100644
index 0000000..ac6a19f
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Application/SecretUseCases.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.Logging;
+using JdeScoping.ConfigManager.Services.SecureStore;
+
+namespace JdeScoping.ConfigManager.Application;
+
+///
+/// Secret CRUD use-case operations with logging.
+///
+public class SecretUseCases
+{
+ private readonly ISecureStoreManager _storeManager;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The secure store manager.
+ /// The logger instance.
+ public SecretUseCases(ISecureStoreManager storeManager, ILogger logger)
+ {
+ _storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Sets a secret with the given key and value.
+ ///
+ /// The secret key.
+ /// The secret value.
+ public void SetSecret(string key, string value)
+ {
+ _logger.LogInformation("Setting secret {Key}", key);
+ _storeManager.SetSecret(key, value);
+ }
+
+ ///
+ /// Removes a secret by key.
+ ///
+ /// The secret key to remove.
+ public void RemoveSecret(string key)
+ {
+ _logger.LogInformation("Removing secret {Key}", key);
+ _storeManager.RemoveSecret(key);
+ }
+
+ ///
+ /// Gets all keys in the current store.
+ ///
+ public IReadOnlyList GetKeys()
+ {
+ return _storeManager.GetKeys();
+ }
+
+ ///
+ /// Gets the value of a secret by key.
+ ///
+ /// The secret key.
+ /// The secret value.
+ public string GetSecret(string key)
+ {
+ return _storeManager.GetSecret(key);
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs
new file mode 100644
index 0000000..8dd9840
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs
@@ -0,0 +1,116 @@
+using Microsoft.Extensions.Logging;
+using JdeScoping.ConfigManager.Services.SecureStore;
+
+namespace JdeScoping.ConfigManager.Application;
+
+///
+/// Store lifecycle use-case operations with logging.
+///
+public class StoreUseCases
+{
+ private readonly ISecureStoreManager _storeManager;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The secure store manager instance.
+ /// The logger instance.
+ public StoreUseCases(ISecureStoreManager storeManager, ILogger logger)
+ {
+ _storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Creates a new store with either key file or password authentication.
+ ///
+ /// The path where the store will be created.
+ /// The path to the key file, or null for password-based authentication.
+ /// The password for authentication, or null for key file-based authentication.
+ public void CreateStore(string storePath, string? keyFilePath, string? password)
+ {
+ _logger.LogInformation("Creating store at {StorePath}", storePath);
+
+ if (!string.IsNullOrEmpty(keyFilePath))
+ {
+ _storeManager.CreateStore(storePath, keyFilePath);
+ _logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
+ }
+ else if (!string.IsNullOrEmpty(password))
+ {
+ _storeManager.CreateStoreWithPassword(storePath, password);
+ _logger.LogInformation("Password-protected store created");
+ }
+ else
+ {
+ throw new ArgumentException("Either key file path or password must be provided.");
+ }
+ }
+
+ ///
+ /// Opens an existing store with either key file or password authentication.
+ ///
+ /// The path to the existing store.
+ /// The path to the key file, or null for password-based authentication.
+ /// The password for authentication, or null for key file-based authentication.
+ public void OpenStore(string storePath, string? keyFilePath, string? password)
+ {
+ _logger.LogInformation("Opening store at {StorePath}", storePath);
+
+ if (!string.IsNullOrEmpty(keyFilePath))
+ {
+ _storeManager.OpenStore(storePath, keyFilePath);
+ _logger.LogDebug("Store opened with key file");
+ }
+ else if (!string.IsNullOrEmpty(password))
+ {
+ _storeManager.OpenStoreWithPassword(storePath, password);
+ _logger.LogDebug("Store opened with password");
+ }
+ else
+ {
+ throw new ArgumentException("Either key file path or password must be provided.");
+ }
+ }
+
+ ///
+ /// Closes the currently open store.
+ ///
+ public void CloseStore()
+ {
+ _logger.LogInformation("Closing store");
+ _storeManager.CloseStore();
+ }
+
+ ///
+ /// Saves changes to the current store.
+ ///
+ public void Save()
+ {
+ _logger.LogInformation("Saving store");
+ _storeManager.Save();
+ }
+
+ ///
+ /// Generates a new key file at the specified path.
+ ///
+ /// The path where the key file will be generated.
+ public void GenerateKeyFile(string path)
+ {
+ _logger.LogInformation("Generating key file at {Path}", path);
+ _storeManager.GenerateKeyFile(path);
+ _logger.LogInformation("Key file generated successfully");
+ }
+
+ ///
+ /// Exports the current store's key to a file.
+ ///
+ /// The path where the key will be exported.
+ public void ExportKey(string path)
+ {
+ _logger.LogInformation("Exporting key to {Path}", path);
+ _storeManager.ExportKey(path);
+ _logger.LogInformation("Key exported successfully");
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Constants/SecureStoreFileExtensions.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Constants/SecureStoreFileExtensions.cs
new file mode 100644
index 0000000..7586e0a
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Constants/SecureStoreFileExtensions.cs
@@ -0,0 +1,21 @@
+namespace JdeScoping.ConfigManager.Constants;
+
+///
+/// Centralized constants for secure store file extensions and patterns used in file dialogs.
+///
+public static class SecureStoreFileExtensions
+{
+ // 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";
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Constants/SecureStoreStrings.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Constants/SecureStoreStrings.cs
new file mode 100644
index 0000000..aae7077
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Constants/SecureStoreStrings.cs
@@ -0,0 +1,50 @@
+namespace JdeScoping.ConfigManager.Constants;
+
+///
+/// Centralized string constants for secure store dialog titles, messages, and validation errors.
+///
+public static class SecureStoreStrings
+{
+ // 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}";
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Converters/StringToBoolConverter.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Converters/StringToBoolConverter.cs
new file mode 100644
index 0000000..38c5554
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Converters/StringToBoolConverter.cs
@@ -0,0 +1,42 @@
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace JdeScoping.ConfigManager.Converters;
+
+///
+/// Converts a string to bool (empty/null = false, not empty = true).
+/// Used for visibility bindings based on validation error messages.
+///
+public class StringToBoolConverter : IValueConverter
+{
+ ///
+ /// Converts a string to a boolean based on whether it's empty or null.
+ ///
+ /// The string value to check.
+ /// The target type (ignored).
+ /// An optional parameter (ignored).
+ /// The culture information (ignored).
+ /// True if string is not null or whitespace, false otherwise.
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is string str)
+ {
+ return !string.IsNullOrWhiteSpace(str);
+ }
+ return false;
+ }
+
+ ///
+ /// Converts a value back (not implemented for string checks).
+ ///
+ /// The value to convert back (ignored).
+ /// The target type (ignored).
+ /// An optional parameter (ignored).
+ /// The culture information (ignored).
+ /// Not implemented.
+ /// Always thrown.
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj b/NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj
index 073449e..aee17b9 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj
@@ -22,6 +22,7 @@
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaClipboardService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaClipboardService.cs
new file mode 100644
index 0000000..7150718
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaClipboardService.cs
@@ -0,0 +1,33 @@
+using Avalonia.Input.Platform;
+
+namespace JdeScoping.ConfigManager.Services;
+
+///
+/// Avalonia implementation of IClipboardService.
+///
+public class AvaloniaClipboardService : IClipboardService
+{
+ private readonly Func _getClipboard;
+
+ ///
+ /// Creates a new instance of AvaloniaClipboardService.
+ ///
+ /// Factory function to get the clipboard instance.
+ public AvaloniaClipboardService(Func getClipboard)
+ {
+ _getClipboard = getClipboard ?? throw new ArgumentNullException(nameof(getClipboard));
+ }
+
+ ///
+ /// Sets the clipboard text asynchronously.
+ ///
+ /// The text to set on the clipboard.
+ public async Task SetTextAsync(string text)
+ {
+ var clipboard = _getClipboard();
+ if (clipboard != null)
+ {
+ await clipboard.SetTextAsync(text);
+ }
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IClipboardService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IClipboardService.cs
new file mode 100644
index 0000000..d5346da
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IClipboardService.cs
@@ -0,0 +1,14 @@
+namespace JdeScoping.ConfigManager.Services;
+
+///
+/// Abstraction for platform-specific clipboard operations.
+/// Enables unit testing of view models that need clipboard access.
+///
+public interface IClipboardService
+{
+ ///
+ /// Copies text to the system clipboard.
+ ///
+ /// The text to copy.
+ Task SetTextAsync(string text);
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs
new file mode 100644
index 0000000..5bc6747
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs
@@ -0,0 +1,98 @@
+namespace JdeScoping.ConfigManager.Services.SecureStore;
+
+///
+/// Interface for managing SecureStore encrypted secret stores.
+///
+public interface ISecureStoreManager
+{
+ ///
+ /// Gets whether a store is currently open.
+ ///
+ bool IsStoreOpen { get; }
+
+ ///
+ /// Gets the path to the currently open store, or null if no store is open.
+ ///
+ string? CurrentStorePath { get; }
+
+ ///
+ /// Gets whether there are unsaved changes to the current store.
+ ///
+ bool HasUnsavedChanges { get; }
+
+ ///
+ /// Creates a new store secured with a key file.
+ ///
+ /// Path for the new store file (.json).
+ /// Path for the key file (.key).
+ void CreateStore(string storePath, string keyFilePath);
+
+ ///
+ /// Creates a new store secured with a password.
+ ///
+ /// Path for the new store file (.json).
+ /// Password to encrypt the store.
+ void CreateStoreWithPassword(string storePath, string password);
+
+ ///
+ /// Opens an existing store using a key file.
+ ///
+ /// Path to the store file (.json).
+ /// Path to the key file (.key).
+ void OpenStore(string storePath, string keyFilePath);
+
+ ///
+ /// Opens an existing store using a password.
+ ///
+ /// Path to the store file (.json).
+ /// Password to decrypt the store.
+ void OpenStoreWithPassword(string storePath, string password);
+
+ ///
+ /// Closes the currently open store without saving.
+ ///
+ void CloseStore();
+
+ ///
+ /// Saves changes to the currently open store.
+ ///
+ void Save();
+
+ ///
+ /// Gets all secret keys in the current store.
+ ///
+ /// Collection of secret key names.
+ IReadOnlyList GetKeys();
+
+ ///
+ /// Gets the value of a secret.
+ ///
+ /// The secret key.
+ /// The decrypted secret value.
+ string GetSecret(string key);
+
+ ///
+ /// Sets or updates a secret value.
+ ///
+ /// The secret key.
+ /// The value to encrypt and store.
+ void SetSecret(string key, string value);
+
+ ///
+ /// Removes a secret from the store.
+ ///
+ /// The secret key to remove.
+ void RemoveSecret(string key);
+
+ ///
+ /// Generates a new key file for use with store encryption.
+ ///
+ /// Path where the key file will be created.
+ void GenerateKeyFile(string path);
+
+ ///
+ /// Exports the current store's key to a file (for key file-based stores).
+ ///
+ /// Path where the key will be exported.
+ void ExportKey(string path);
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs
new file mode 100644
index 0000000..2f27838
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs
@@ -0,0 +1,336 @@
+using System.IO;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using NeoSmart.SecureStore;
+
+namespace JdeScoping.ConfigManager.Services.SecureStore;
+
+///
+/// Manages SecureStore encrypted secret stores for the Avalonia application.
+///
+public class SecureStoreManager : ISecureStoreManager, IDisposable
+{
+ private readonly ILogger _logger;
+ private SecretsManager? _secretsManager;
+ private string? _currentStorePath;
+ private readonly HashSet _keys = new();
+ private bool _hasUnsavedChanges;
+ private bool _disposed;
+
+ private const string KeysMetadataKey = "__keys__";
+
+ private static readonly HashSet ReservedKeys = new(StringComparer.OrdinalIgnoreCase)
+ {
+ KeysMetadataKey
+ };
+
+ ///
+ /// Creates a new SecureStoreManager with no logging.
+ ///
+ public SecureStoreManager() : this(NullLogger.Instance)
+ {
+ }
+
+ ///
+ /// Creates a new SecureStoreManager with the specified logger.
+ ///
+ /// Logger instance for diagnostic output.
+ public SecureStoreManager(ILogger logger)
+ {
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ ///
+ public bool IsStoreOpen => _secretsManager != null;
+
+ ///
+ public string? CurrentStorePath => _currentStorePath;
+
+ ///
+ public bool HasUnsavedChanges => _hasUnsavedChanges;
+
+ ///
+ 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);
+ }
+
+ ///
+ public void CreateStoreWithPassword(string storePath, string password)
+ {
+ ThrowIfDisposed();
+ _logger.LogInformation("Creating password-protected store at {StorePath}", storePath);
+ CloseStoreInternal();
+
+ if (string.IsNullOrEmpty(password))
+ throw new ArgumentException("Password cannot be empty.", nameof(password));
+
+ EnsureDirectory(storePath);
+
+ _secretsManager = SecretsManager.CreateStore();
+ _secretsManager.LoadKeyFromPassword(password);
+
+ _currentStorePath = storePath;
+ _keys.Clear();
+ _hasUnsavedChanges = true;
+
+ Save();
+ _logger.LogInformation("Password-protected store created");
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ public void OpenStoreWithPassword(string storePath, string password)
+ {
+ ThrowIfDisposed();
+ _logger.LogInformation("Opening store at {StorePath} with password", storePath);
+ CloseStoreInternal();
+
+ if (!File.Exists(storePath))
+ throw new FileNotFoundException("Store file not found.", storePath);
+
+ if (string.IsNullOrEmpty(password))
+ throw new ArgumentException("Password cannot be empty.", nameof(password));
+
+ _secretsManager = SecretsManager.LoadStore(storePath);
+ _secretsManager.LoadKeyFromPassword(password);
+
+ _currentStorePath = storePath;
+ LoadKeysMetadata();
+ _hasUnsavedChanges = false;
+ _logger.LogDebug("Store opened with password, contains {KeyCount} keys", _keys.Count);
+ }
+
+ ///
+ public void CloseStore()
+ {
+ ThrowIfDisposed();
+ _logger.LogInformation("Closing store");
+ CloseStoreInternal();
+ }
+
+ ///
+ 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;
+ }
+
+ ///
+ public IReadOnlyList GetKeys()
+ {
+ ThrowIfDisposed();
+
+ if (_secretsManager == null)
+ throw new InvalidOperationException("No store is currently open.");
+
+ return _keys.Where(k => k != KeysMetadataKey).ToList().AsReadOnly();
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ 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;
+ }
+
+ ///
+ 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;
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ 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(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);
+ }
+
+ ///
+ /// Releases the resources used by the .
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+
+ _secretsManager?.Dispose();
+ _secretsManager = null;
+ _disposed = true;
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/NewStoreDialogViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/NewStoreDialogViewModel.cs
new file mode 100644
index 0000000..4a0322e
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/NewStoreDialogViewModel.cs
@@ -0,0 +1,243 @@
+using System.Windows.Input;
+using JdeScoping.ConfigManager.Constants;
+
+namespace JdeScoping.ConfigManager.ViewModels.Dialogs;
+
+///
+/// View model for creating a new secure store.
+///
+public class NewStoreDialogViewModel : ViewModelBase
+{
+ private string _storePath = string.Empty;
+ private string _keyFilePath = string.Empty;
+ private string _password = string.Empty;
+ private string _confirmPassword = string.Empty;
+ private bool _useKeyFile = true;
+ private bool _usePassword;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public NewStoreDialogViewModel()
+ {
+ BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
+ BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
+ GenerateKeyFileCommand = new AsyncRelayCommand(GenerateKeyFileAsync);
+ }
+
+ ///
+ /// Gets or sets the path to the store file to create.
+ ///
+ public string StorePath
+ {
+ get => _storePath;
+ set
+ {
+ if (SetProperty(ref _storePath, value))
+ NotifyValidationChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the path to the key file for encryption.
+ ///
+ public string KeyFilePath
+ {
+ get => _keyFilePath;
+ set
+ {
+ if (SetProperty(ref _keyFilePath, value))
+ NotifyValidationChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the password for store encryption.
+ ///
+ public string Password
+ {
+ get => _password;
+ set
+ {
+ if (SetProperty(ref _password, value))
+ NotifyValidationChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the password confirmation value.
+ ///
+ public string ConfirmPassword
+ {
+ get => _confirmPassword;
+ set
+ {
+ if (SetProperty(ref _confirmPassword, value))
+ NotifyValidationChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets whether to use key file for store encryption.
+ ///
+ public bool UseKeyFile
+ {
+ get => _useKeyFile;
+ set
+ {
+ if (SetProperty(ref _useKeyFile, value))
+ {
+ if (value) UsePassword = false;
+ NotifyValidationChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets whether to use password for store encryption.
+ ///
+ public bool UsePassword
+ {
+ get => _usePassword;
+ set
+ {
+ if (SetProperty(ref _usePassword, value))
+ {
+ if (value) UseKeyFile = false;
+ NotifyValidationChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets the command to browse for store path location.
+ ///
+ public ICommand BrowseStorePathCommand { get; }
+
+ ///
+ /// Gets the command to browse for key file path location.
+ ///
+ public ICommand BrowseKeyFilePathCommand { get; }
+
+ ///
+ /// Gets the command to generate a new key file.
+ ///
+ public ICommand GenerateKeyFileCommand { get; }
+
+ ///
+ /// Gets a value indicating whether the dialog input is valid.
+ ///
+ public bool IsValid
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(StorePath))
+ return false;
+
+ if (UseKeyFile)
+ return !string.IsNullOrWhiteSpace(KeyFilePath);
+
+ if (UsePassword)
+ return !string.IsNullOrWhiteSpace(Password) && Password == ConfirmPassword;
+
+ return false;
+ }
+ }
+
+ ///
+ /// Gets the validation error message, or null if valid.
+ ///
+ public string? ValidationError
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(StorePath))
+ return SecureStoreStrings.StorePathRequired;
+
+ if (UseKeyFile && string.IsNullOrWhiteSpace(KeyFilePath))
+ return SecureStoreStrings.KeyFilePathRequired;
+
+ if (UsePassword)
+ {
+ if (string.IsNullOrWhiteSpace(Password))
+ return SecureStoreStrings.PasswordRequired;
+
+ if (Password != ConfirmPassword)
+ return SecureStoreStrings.PasswordsDoNotMatch;
+ }
+
+ return null;
+ }
+ }
+
+ ///
+ /// Event raised to request save file dialog for store path.
+ /// Parameters: title, fileTypeName, pattern, defaultExtension
+ /// Returns: selected file path or null
+ ///
+ public event Func>? OnShowSaveFileDialog;
+
+ ///
+ /// Event raised to request key file generation.
+ /// Parameters: title, fileTypeName, pattern, defaultExtension
+ /// Returns: generated key file path or null
+ ///
+ public event Func>? OnGenerateKeyFile;
+
+ private void NotifyValidationChanged()
+ {
+ OnPropertyChanged(nameof(IsValid));
+ OnPropertyChanged(nameof(ValidationError));
+ }
+
+ private async void BrowseStorePath()
+ {
+ if (OnShowSaveFileDialog == null)
+ return;
+
+ var path = await OnShowSaveFileDialog(
+ SecureStoreStrings.ChooseStoreLocation,
+ SecureStoreFileExtensions.StoreTypeName,
+ SecureStoreFileExtensions.StorePattern,
+ SecureStoreFileExtensions.StoreExtension);
+
+ if (!string.IsNullOrEmpty(path))
+ {
+ StorePath = path;
+ }
+ }
+
+ private async void BrowseKeyFilePath()
+ {
+ if (OnShowSaveFileDialog == null)
+ return;
+
+ var path = await OnShowSaveFileDialog(
+ SecureStoreStrings.ChooseKeyFileLocation,
+ SecureStoreFileExtensions.KeyTypeName,
+ SecureStoreFileExtensions.KeyPattern,
+ SecureStoreFileExtensions.KeyExtension);
+
+ if (!string.IsNullOrEmpty(path))
+ {
+ KeyFilePath = path;
+ }
+ }
+
+ private async Task GenerateKeyFileAsync()
+ {
+ if (OnGenerateKeyFile == null)
+ return;
+
+ var path = await OnGenerateKeyFile(
+ SecureStoreStrings.GenerateKeyFileTitle,
+ SecureStoreFileExtensions.KeyTypeName,
+ SecureStoreFileExtensions.KeyPattern,
+ SecureStoreFileExtensions.KeyExtension);
+
+ if (!string.IsNullOrEmpty(path))
+ {
+ KeyFilePath = path;
+ }
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/SecretEditDialogViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/SecretEditDialogViewModel.cs
new file mode 100644
index 0000000..f162983
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/SecretEditDialogViewModel.cs
@@ -0,0 +1,106 @@
+using JdeScoping.ConfigManager.Constants;
+
+namespace JdeScoping.ConfigManager.ViewModels.Dialogs;
+
+///
+/// View model for adding or editing a secret in the secure store.
+///
+public class SecretEditDialogViewModel : ViewModelBase
+{
+ private string _key = string.Empty;
+ private string _value = string.Empty;
+ private bool _isNewSecret = true;
+
+ ///
+ /// Initializes a new instance of the class for adding a new secret.
+ ///
+ public SecretEditDialogViewModel()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class for editing an existing secret.
+ ///
+ /// The secret key (read-only when editing).
+ /// The current secret value.
+ public SecretEditDialogViewModel(string key, string value)
+ {
+ _key = key;
+ _value = value;
+ _isNewSecret = false;
+ }
+
+ ///
+ /// Gets or sets the secret key.
+ ///
+ public string Key
+ {
+ get => _key;
+ set
+ {
+ if (SetProperty(ref _key, value))
+ NotifyValidationChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the secret value.
+ ///
+ public string Value
+ {
+ get => _value;
+ set => SetProperty(ref _value, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether this is a new secret being added.
+ ///
+ public bool IsNewSecret
+ {
+ get => _isNewSecret;
+ set
+ {
+ if (SetProperty(ref _isNewSecret, value))
+ {
+ OnPropertyChanged(nameof(IsKeyEditable));
+ OnPropertyChanged(nameof(DialogTitle));
+ }
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the key field is editable.
+ /// The key is only editable when adding a new secret.
+ ///
+ public bool IsKeyEditable => _isNewSecret;
+
+ ///
+ /// Gets the dialog title based on whether this is a new secret or edit.
+ ///
+ public string DialogTitle => _isNewSecret ? "Add Secret" : "Edit Secret";
+
+ ///
+ /// Gets a value indicating whether the dialog input is valid.
+ ///
+ public bool IsValid => !string.IsNullOrWhiteSpace(Key);
+
+ ///
+ /// Gets the validation error message, or null if valid.
+ ///
+ public string? ValidationError
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(Key))
+ return SecureStoreStrings.KeyRequired;
+
+ return null;
+ }
+ }
+
+ private void NotifyValidationChanged()
+ {
+ OnPropertyChanged(nameof(IsValid));
+ OnPropertyChanged(nameof(ValidationError));
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/UnlockStoreDialogViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/UnlockStoreDialogViewModel.cs
new file mode 100644
index 0000000..0f60d44
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/UnlockStoreDialogViewModel.cs
@@ -0,0 +1,169 @@
+using System.Windows.Input;
+using JdeScoping.ConfigManager.Constants;
+
+namespace JdeScoping.ConfigManager.ViewModels.Dialogs;
+
+///
+/// View model for unlocking an existing secure store.
+///
+public class UnlockStoreDialogViewModel : ViewModelBase
+{
+ private readonly string _storePath;
+ private string _keyFilePath = string.Empty;
+ private string _password = string.Empty;
+ private bool _useKeyFile = true;
+ private bool _usePassword;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The path to the store file to unlock.
+ public UnlockStoreDialogViewModel(string storePath)
+ {
+ _storePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
+ BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
+ }
+
+ ///
+ /// Gets the path to the store file to unlock (read-only).
+ ///
+ public string StorePath => _storePath;
+
+ ///
+ /// Gets or sets the path to the key file for decryption.
+ ///
+ public string KeyFilePath
+ {
+ get => _keyFilePath;
+ set
+ {
+ if (SetProperty(ref _keyFilePath, value))
+ NotifyValidationChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the password for store decryption.
+ ///
+ public string Password
+ {
+ get => _password;
+ set
+ {
+ if (SetProperty(ref _password, value))
+ NotifyValidationChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets whether to use key file for store decryption.
+ ///
+ public bool UseKeyFile
+ {
+ get => _useKeyFile;
+ set
+ {
+ if (SetProperty(ref _useKeyFile, value))
+ {
+ if (value) UsePassword = false;
+ NotifyValidationChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets whether to use password for store decryption.
+ ///
+ public bool UsePassword
+ {
+ get => _usePassword;
+ set
+ {
+ if (SetProperty(ref _usePassword, value))
+ {
+ if (value) UseKeyFile = false;
+ NotifyValidationChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets the command to browse for key file path location.
+ ///
+ public ICommand BrowseKeyFilePathCommand { get; }
+
+ ///
+ /// Gets a value indicating whether the dialog input is valid.
+ ///
+ public bool IsValid
+ {
+ get
+ {
+ if (UseKeyFile)
+ {
+ if (string.IsNullOrWhiteSpace(KeyFilePath))
+ return false;
+
+ if (!System.IO.File.Exists(KeyFilePath))
+ return false;
+ }
+
+ if (UsePassword)
+ return !string.IsNullOrWhiteSpace(Password);
+
+ return false;
+ }
+ }
+
+ ///
+ /// Gets the validation error message, or null if valid.
+ ///
+ public string? ValidationError
+ {
+ get
+ {
+ if (UseKeyFile)
+ {
+ if (string.IsNullOrWhiteSpace(KeyFilePath))
+ return SecureStoreStrings.KeyFilePathRequired;
+
+ if (!System.IO.File.Exists(KeyFilePath))
+ return SecureStoreStrings.KeyFileNotFound;
+ }
+
+ if (UsePassword && string.IsNullOrWhiteSpace(Password))
+ return SecureStoreStrings.PasswordRequired;
+
+ return null;
+ }
+ }
+
+ ///
+ /// Event raised to request open file dialog for key file path.
+ /// Parameters: title, fileTypeName, pattern
+ /// Returns: selected file path or null
+ ///
+ public event Func>? OnShowOpenFileDialog;
+
+ private void NotifyValidationChanged()
+ {
+ OnPropertyChanged(nameof(IsValid));
+ OnPropertyChanged(nameof(ValidationError));
+ }
+
+ private async void BrowseKeyFilePath()
+ {
+ if (OnShowOpenFileDialog == null)
+ return;
+
+ var path = await OnShowOpenFileDialog(
+ SecureStoreStrings.SelectKeyFile,
+ SecureStoreFileExtensions.KeyTypeName,
+ SecureStoreFileExtensions.KeyPattern);
+
+ if (!string.IsNullOrEmpty(path))
+ {
+ KeyFilePath = path;
+ }
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecretFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecretFormViewModel.cs
new file mode 100644
index 0000000..9439ad3
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecretFormViewModel.cs
@@ -0,0 +1,109 @@
+using System.Windows.Input;
+using JdeScoping.ConfigManager.Services;
+
+namespace JdeScoping.ConfigManager.ViewModels.Forms;
+
+///
+/// ViewModel for displaying and editing a secret.
+///
+public class SecretFormViewModel : ViewModelBase
+{
+ private readonly IClipboardService _clipboardService;
+ private readonly Action _onValueChanged;
+ private readonly Action _onDeleteRequested;
+ private string _value;
+ private bool _isValueVisible;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The secret key (read-only).
+ /// The initial secret value.
+ /// The clipboard service for copying values.
+ /// The action to invoke when the value changes.
+ /// The action to invoke when deletion is requested.
+ public SecretFormViewModel(
+ string key,
+ string value,
+ IClipboardService clipboardService,
+ Action onValueChanged,
+ Action onDeleteRequested)
+ {
+ Key = key ?? throw new ArgumentNullException(nameof(key));
+ _value = value ?? string.Empty;
+ _clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService));
+ _onValueChanged = onValueChanged ?? throw new ArgumentNullException(nameof(onValueChanged));
+ _onDeleteRequested = onDeleteRequested ?? throw new ArgumentNullException(nameof(onDeleteRequested));
+
+ ToggleVisibilityCommand = new RelayCommand(() => IsValueVisible = !IsValueVisible);
+ CopyToClipboardCommand = new AsyncRelayCommand(CopyToClipboardAsync);
+ DeleteCommand = new RelayCommand(_onDeleteRequested);
+ }
+
+ ///
+ /// Gets the secret key (read-only).
+ ///
+ public string Key { get; }
+
+ ///
+ /// Gets or sets the secret value.
+ ///
+ public string Value
+ {
+ get => _value;
+ set
+ {
+ if (SetProperty(ref _value, value))
+ {
+ _onValueChanged(value);
+ OnPropertyChanged(nameof(DisplayValue));
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets whether the value is visible (unmasked).
+ ///
+ public bool IsValueVisible
+ {
+ get => _isValueVisible;
+ set
+ {
+ if (SetProperty(ref _isValueVisible, value))
+ {
+ OnPropertyChanged(nameof(DisplayValue));
+ OnPropertyChanged(nameof(VisibilityButtonText));
+ }
+ }
+ }
+
+ ///
+ /// Gets the display value (masked or unmasked based on visibility).
+ ///
+ public string DisplayValue => IsValueVisible ? Value : new string('\u2022', Math.Min(Value?.Length ?? 0, 20));
+
+ ///
+ /// Gets the text for the visibility toggle button.
+ ///
+ public string VisibilityButtonText => IsValueVisible ? "Hide" : "Show";
+
+ ///
+ /// Gets the command to toggle value visibility.
+ ///
+ public ICommand ToggleVisibilityCommand { get; }
+
+ ///
+ /// Gets the command to copy the value to clipboard.
+ ///
+ public ICommand CopyToClipboardCommand { get; }
+
+ ///
+ /// Gets the command to delete this secret.
+ ///
+ public ICommand DeleteCommand { get; }
+
+ private async Task CopyToClipboardAsync()
+ {
+ await _clipboardService.SetTextAsync(Value);
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs
new file mode 100644
index 0000000..47f57bb
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs
@@ -0,0 +1,52 @@
+using System.Windows.Input;
+
+namespace JdeScoping.ConfigManager.ViewModels.Forms;
+
+///
+/// ViewModel for displaying a locked secure store with unlock capability.
+///
+public class SecureStoreLockedFormViewModel : ViewModelBase
+{
+ private readonly Action _onUnlockRequested;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the secure store.
+ /// The full path to the secure store file.
+ /// The last modified date of the store file.
+ /// The action to invoke when unlock is requested.
+ public SecureStoreLockedFormViewModel(
+ string storeName,
+ string storePath,
+ DateTime? lastModified,
+ Action onUnlockRequested)
+ {
+ StoreName = storeName ?? throw new ArgumentNullException(nameof(storeName));
+ StorePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
+ LastModified = lastModified;
+ _onUnlockRequested = onUnlockRequested ?? throw new ArgumentNullException(nameof(onUnlockRequested));
+
+ UnlockCommand = new RelayCommand(_onUnlockRequested);
+ }
+
+ ///
+ /// Gets the name of the secure store.
+ ///
+ public string StoreName { get; }
+
+ ///
+ /// Gets the full path to the secure store file.
+ ///
+ public string StorePath { get; }
+
+ ///
+ /// Gets the last modified date of the store file.
+ ///
+ public DateTime? LastModified { get; }
+
+ ///
+ /// Gets the command to unlock the store.
+ ///
+ public ICommand UnlockCommand { get; }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs
new file mode 100644
index 0000000..9b41796
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs
@@ -0,0 +1,92 @@
+using System.Windows.Input;
+
+namespace JdeScoping.ConfigManager.ViewModels.Forms;
+
+///
+/// ViewModel for displaying an unlocked secure store.
+///
+public class SecureStoreUnlockedFormViewModel : ViewModelBase
+{
+ private readonly Action _onLockRequested;
+ private readonly Action _onAddSecretRequested;
+ private readonly Action _onSaveRequested;
+ private bool _hasUnsavedChanges;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the secure store.
+ /// The full path to the secure store file.
+ /// The number of secrets in the store.
+ /// Whether the store has unsaved changes.
+ /// The action to invoke when lock is requested.
+ /// The action to invoke when adding a new secret is requested.
+ /// The action to invoke when save is requested.
+ public SecureStoreUnlockedFormViewModel(
+ string storeName,
+ string storePath,
+ int secretCount,
+ bool hasUnsavedChanges,
+ Action onLockRequested,
+ Action onAddSecretRequested,
+ Action onSaveRequested)
+ {
+ StoreName = storeName ?? throw new ArgumentNullException(nameof(storeName));
+ StorePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
+ SecretCount = secretCount;
+ _hasUnsavedChanges = hasUnsavedChanges;
+ _onLockRequested = onLockRequested ?? throw new ArgumentNullException(nameof(onLockRequested));
+ _onAddSecretRequested = onAddSecretRequested ?? throw new ArgumentNullException(nameof(onAddSecretRequested));
+ _onSaveRequested = onSaveRequested ?? throw new ArgumentNullException(nameof(onSaveRequested));
+
+ LockCommand = new RelayCommand(_onLockRequested);
+ AddSecretCommand = new RelayCommand(_onAddSecretRequested);
+ SaveCommand = new RelayCommand(_onSaveRequested, () => HasUnsavedChanges);
+ }
+
+ ///
+ /// Gets the name of the secure store.
+ ///
+ public string StoreName { get; }
+
+ ///
+ /// Gets the full path to the secure store file.
+ ///
+ public string StorePath { get; }
+
+ ///
+ /// Gets the number of secrets in the store.
+ ///
+ public int SecretCount { get; }
+
+ ///
+ /// Gets or sets whether the store has unsaved changes.
+ ///
+ public bool HasUnsavedChanges
+ {
+ get => _hasUnsavedChanges;
+ set
+ {
+ if (SetProperty(ref _hasUnsavedChanges, value))
+ {
+ // Notify that SaveCommand's CanExecute may have changed
+ (SaveCommand as RelayCommand)?.RaiseCanExecuteChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets the command to lock the store.
+ ///
+ public ICommand LockCommand { get; }
+
+ ///
+ /// Gets the command to add a new secret.
+ ///
+ public ICommand AddSecretCommand { get; }
+
+ ///
+ /// Gets the command to save the store.
+ ///
+ public ICommand SaveCommand { get; }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs
index 0c941a8..747474c 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs
@@ -1,8 +1,11 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using Avalonia.Media;
+using JdeScoping.ConfigManager.Constants;
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
+using JdeScoping.ConfigManager.Services.SecureStore;
+using JdeScoping.ConfigManager.ViewModels.Dialogs;
using JdeScoping.ConfigManager.ViewModels.Forms;
using Microsoft.Extensions.Logging;
@@ -19,6 +22,8 @@ public class MainWindowViewModel : ViewModelBase
private readonly IBackupService _backupService;
private readonly IAutoDiscoveryService _autoDiscoveryService;
private readonly IDialogService? _dialogService;
+ private readonly ISecureStoreManager _secureStoreManager;
+ private readonly IClipboardService _clipboardService;
private readonly ILogger? _logger;
private string _configFolderPath = "No folder selected";
@@ -31,6 +36,10 @@ public class MainWindowViewModel : ViewModelBase
private ConfigModel? _appSettings;
private PipelinesConfigModel? _pipelines;
+ // SecureStore state tracking
+ private readonly Dictionary _openStores = new();
+ private TreeNodeViewModel? _selectedStoreNode;
+
///
/// Gets or sets the currently loaded configuration folder path.
///
@@ -129,6 +138,51 @@ public class MainWindowViewModel : ViewModelBase
///
public ICommand TestConnectionCommand { get; }
+ ///
+ /// Gets the command for creating a new secure store.
+ ///
+ public ICommand NewStoreCommand { get; }
+
+ ///
+ /// Gets the command for adding an existing secure store.
+ ///
+ public ICommand AddExistingStoreCommand { get; }
+
+ ///
+ /// Gets the command for unlocking a secure store.
+ ///
+ public ICommand UnlockStoreCommand { get; }
+
+ ///
+ /// Gets the command for locking a secure store.
+ ///
+ public ICommand LockStoreCommand { get; }
+
+ ///
+ /// Gets the command for saving a secure store.
+ ///
+ public ICommand SaveStoreCommand { get; }
+
+ ///
+ /// Gets the command for locking all open secure stores.
+ ///
+ public ICommand LockAllStoresCommand { get; }
+
+ ///
+ /// Gets the command for generating a new key file.
+ ///
+ public ICommand GenerateKeyFileCommand { get; }
+
+ ///
+ /// Gets the command for adding a secret to the current store.
+ ///
+ public ICommand AddSecretCommand { get; }
+
+ ///
+ /// Gets the command for deleting a secret from the current store.
+ ///
+ public ICommand DeleteSecretCommand { get; }
+
///
/// Initializes a new instance of the class.
///
@@ -138,6 +192,8 @@ public class MainWindowViewModel : ViewModelBase
/// Service for creating configuration backups.
/// Service for discovering configuration folder locations.
/// Service for showing platform dialogs.
+ /// Service for managing encrypted secret stores.
+ /// Service for clipboard operations.
/// Optional logger for recording view model activities.
public MainWindowViewModel(
IFileSystem fileSystem,
@@ -146,6 +202,8 @@ public class MainWindowViewModel : ViewModelBase
IBackupService backupService,
IAutoDiscoveryService autoDiscoveryService,
IDialogService? dialogService,
+ ISecureStoreManager secureStoreManager,
+ IClipboardService clipboardService,
ILogger? logger)
{
_fileSystem = fileSystem;
@@ -154,6 +212,8 @@ public class MainWindowViewModel : ViewModelBase
_backupService = backupService;
_autoDiscoveryService = autoDiscoveryService;
_dialogService = dialogService;
+ _secureStoreManager = secureStoreManager;
+ _clipboardService = clipboardService;
_logger = logger;
OpenFolderCommand = new AsyncRelayCommand(OpenFolderAsync);
@@ -164,6 +224,17 @@ public class MainWindowViewModel : ViewModelBase
ValidateCommand = new RelayCommand(Validate);
TestConnectionCommand = new AsyncRelayCommand(TestConnectionAsync);
+ // SecureStore commands
+ NewStoreCommand = new AsyncRelayCommand(NewStoreAsync);
+ AddExistingStoreCommand = new AsyncRelayCommand(AddExistingStoreAsync);
+ UnlockStoreCommand = new AsyncRelayCommand(UnlockStoreAsync, CanUnlockStore);
+ LockStoreCommand = new RelayCommand(LockStore, CanLockStore);
+ SaveStoreCommand = new AsyncRelayCommand(SaveStoreAsync, CanSaveStore);
+ LockAllStoresCommand = new RelayCommand(LockAllStores, () => _openStores.Count > 0);
+ GenerateKeyFileCommand = new AsyncRelayCommand(GenerateKeyFileAsync);
+ AddSecretCommand = new AsyncRelayCommand(AddSecretAsync, CanAddSecret);
+ DeleteSecretCommand = new AsyncRelayCommand(DeleteSecretAsync, CanDeleteSecret);
+
_ = InitializeAsync();
}
@@ -177,10 +248,20 @@ public class MainWindowViewModel : ViewModelBase
new BackupService(new FileSystem()),
new AutoDiscoveryService(new FileSystem()),
null,
+ new SecureStoreManager(),
+ new NullClipboardService(),
null)
{
}
+ ///
+ /// Null implementation of clipboard service for design-time.
+ ///
+ private class NullClipboardService : IClipboardService
+ {
+ public Task SetTextAsync(string text) => Task.CompletedTask;
+ }
+
///
/// Initializes the view model by auto-discovering and loading configuration.
///
@@ -269,6 +350,49 @@ public class MainWindowViewModel : ViewModelBase
}
}
TreeNodes.Add(pipelinesFolder);
+
+ // Secure Stores folder
+ var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "key", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
+ DiscoverSecureStores(secureStoresFolder);
+ TreeNodes.Add(secureStoresFolder);
+ }
+
+ ///
+ /// Discovers existing secure store files in the configuration folder.
+ ///
+ /// The parent tree node to add discovered stores to.
+ private void DiscoverSecureStores(TreeNodeViewModel parentNode)
+ {
+ if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
+ return;
+
+ try
+ {
+ // Look for *.secrets.json files in the config folder
+ var secretsFiles = Directory.GetFiles(ConfigFolderPath, "*.secrets.json", SearchOption.TopDirectoryOnly);
+
+ foreach (var filePath in secretsFiles)
+ {
+ var fileName = Path.GetFileName(filePath);
+ var storeName = Path.GetFileNameWithoutExtension(fileName);
+ if (storeName.EndsWith(".secrets"))
+ storeName = storeName[..^8]; // Remove ".secrets" suffix for display
+
+ var storeNode = new TreeNodeViewModel(storeName, "lock", TreeNodeType.SecureStore)
+ {
+ StorePath = filePath,
+ SectionKey = filePath,
+ IsUnlocked = false
+ };
+ parentNode.Children.Add(storeNode);
+ }
+
+ _logger?.LogDebug("Discovered {Count} secure store files", secretsFiles.Length);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(ex, "Failed to discover secure store files");
+ }
}
///
@@ -276,12 +400,51 @@ public class MainWindowViewModel : ViewModelBase
///
private void OnSelectedNodeChanged()
{
- if (_selectedNode == null || _appSettings == null)
+ if (_selectedNode == null)
{
SelectedFormViewModel = null;
+ _selectedStoreNode = null;
return;
}
+ // Handle SecureStore-related node types first
+ switch (_selectedNode.NodeType)
+ {
+ case TreeNodeType.SecureStoresFolder:
+ SelectedFormViewModel = null; // Show empty state or instructions
+ _selectedStoreNode = null;
+ return;
+
+ case TreeNodeType.SecureStore:
+ _selectedStoreNode = _selectedNode;
+ if (_selectedNode.IsUnlocked)
+ {
+ SelectedFormViewModel = CreateUnlockedStoreFormViewModel(_selectedNode);
+ }
+ else
+ {
+ SelectedFormViewModel = CreateLockedStoreFormViewModel(_selectedNode);
+ }
+ RaiseSecureStoreCommandsCanExecuteChanged();
+ return;
+
+ case TreeNodeType.Secret:
+ // Find the parent store node
+ _selectedStoreNode = FindParentStoreNode(_selectedNode);
+ SelectedFormViewModel = CreateSecretFormViewModel(_selectedNode);
+ RaiseSecureStoreCommandsCanExecuteChanged();
+ return;
+ }
+
+ // Handle standard configuration sections
+ if (_appSettings == null)
+ {
+ SelectedFormViewModel = null;
+ _selectedStoreNode = null;
+ return;
+ }
+
+ _selectedStoreNode = null;
SelectedFormViewModel = _selectedNode.SectionKey switch
{
"DataSync" => new DataSyncFormViewModel(_appSettings.DataSync, MarkAsChanged),
@@ -296,6 +459,116 @@ public class MainWindowViewModel : ViewModelBase
: null,
_ => null
};
+ RaiseSecureStoreCommandsCanExecuteChanged();
+ }
+
+ ///
+ /// Creates a form view model for a locked secure store.
+ ///
+ private SecureStoreLockedFormViewModel CreateLockedStoreFormViewModel(TreeNodeViewModel storeNode)
+ {
+ DateTime? lastModified = null;
+ if (!string.IsNullOrEmpty(storeNode.StorePath) && File.Exists(storeNode.StorePath))
+ {
+ lastModified = File.GetLastWriteTime(storeNode.StorePath);
+ }
+
+ return new SecureStoreLockedFormViewModel(
+ storeNode.Name,
+ storeNode.StorePath ?? string.Empty,
+ lastModified,
+ () => _ = UnlockStoreAsync());
+ }
+
+ ///
+ /// Creates a form view model for an unlocked secure store.
+ ///
+ private SecureStoreUnlockedFormViewModel CreateUnlockedStoreFormViewModel(TreeNodeViewModel storeNode)
+ {
+ var secretCount = storeNode.Children.Count;
+ var hasUnsavedChanges = _secureStoreManager.HasUnsavedChanges;
+
+ return new SecureStoreUnlockedFormViewModel(
+ storeNode.Name,
+ storeNode.StorePath ?? string.Empty,
+ secretCount,
+ hasUnsavedChanges,
+ () => LockStore(),
+ () => _ = AddSecretAsync(),
+ () => _ = SaveStoreAsync());
+ }
+
+ ///
+ /// Creates a form view model for a secret.
+ ///
+ private SecretFormViewModel? CreateSecretFormViewModel(TreeNodeViewModel secretNode)
+ {
+ if (string.IsNullOrEmpty(secretNode.SecretKey))
+ return null;
+
+ try
+ {
+ var value = _secureStoreManager.GetSecret(secretNode.SecretKey);
+ return new SecretFormViewModel(
+ secretNode.SecretKey,
+ value,
+ _clipboardService,
+ newValue => OnSecretValueChanged(secretNode.SecretKey, newValue),
+ () => _ = DeleteSecretAsync());
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to get secret value for key {Key}", secretNode.SecretKey);
+ return null;
+ }
+ }
+
+ ///
+ /// Called when a secret value changes in the form.
+ ///
+ private void OnSecretValueChanged(string key, string newValue)
+ {
+ try
+ {
+ _secureStoreManager.SetSecret(key, newValue);
+ _logger?.LogDebug("Secret {Key} value updated", key);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to update secret {Key}", key);
+ }
+ }
+
+ ///
+ /// Finds the parent store node for a secret node.
+ ///
+ private TreeNodeViewModel? FindParentStoreNode(TreeNodeViewModel secretNode)
+ {
+ foreach (var rootNode in TreeNodes)
+ {
+ if (rootNode.NodeType == TreeNodeType.SecureStoresFolder)
+ {
+ foreach (var storeNode in rootNode.Children)
+ {
+ if (storeNode.Children.Contains(secretNode))
+ return storeNode;
+ }
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Raises CanExecuteChanged for all SecureStore commands.
+ ///
+ private void RaiseSecureStoreCommandsCanExecuteChanged()
+ {
+ (UnlockStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
+ (LockStoreCommand as RelayCommand)?.RaiseCanExecuteChanged();
+ (SaveStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
+ (LockAllStoresCommand as RelayCommand)?.RaiseCanExecuteChanged();
+ (AddSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
+ (DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
}
///
@@ -408,4 +681,354 @@ public class MainWindowViewModel : ViewModelBase
_logger?.LogInformation("Test connection requested");
await Task.CompletedTask;
}
+
+ #region SecureStore Commands
+
+ ///
+ /// Determines whether a store can be unlocked.
+ ///
+ private bool CanUnlockStore()
+ {
+ return _selectedStoreNode != null
+ && _selectedStoreNode.NodeType == TreeNodeType.SecureStore
+ && !_selectedStoreNode.IsUnlocked;
+ }
+
+ ///
+ /// Determines whether a store can be locked.
+ ///
+ private bool CanLockStore()
+ {
+ return _selectedStoreNode != null
+ && _selectedStoreNode.NodeType == TreeNodeType.SecureStore
+ && _selectedStoreNode.IsUnlocked;
+ }
+
+ ///
+ /// Determines whether the current store can be saved.
+ ///
+ private bool CanSaveStore()
+ {
+ return _selectedStoreNode != null
+ && _selectedStoreNode.IsUnlocked
+ && _secureStoreManager.HasUnsavedChanges;
+ }
+
+ ///
+ /// Determines whether a secret can be added.
+ ///
+ private bool CanAddSecret()
+ {
+ return _selectedStoreNode != null
+ && _selectedStoreNode.IsUnlocked;
+ }
+
+ ///
+ /// Determines whether a secret can be deleted.
+ ///
+ private bool CanDeleteSecret()
+ {
+ return _selectedNode != null
+ && _selectedNode.NodeType == TreeNodeType.Secret
+ && _selectedStoreNode != null
+ && _selectedStoreNode.IsUnlocked;
+ }
+
+ ///
+ /// Creates a new secure store.
+ ///
+ private async Task NewStoreAsync()
+ {
+ if (_dialogService == null)
+ {
+ _logger?.LogWarning("Dialog service is not available");
+ return;
+ }
+
+ // Note: In a full implementation, this would show the NewStoreDialog
+ // For now, we use a simple message dialog to inform the user
+ // A proper implementation would wire up the NewStoreDialogViewModel events
+ await _dialogService.ShowMessageAsync(
+ "New Store",
+ "New store creation dialog not yet implemented. Use Add Existing Store instead.");
+ }
+
+ ///
+ /// Adds an existing secure store to the tree.
+ ///
+ private async Task AddExistingStoreAsync()
+ {
+ if (_dialogService == null)
+ {
+ _logger?.LogWarning("Dialog service is not available");
+ return;
+ }
+
+ // Note: In a full implementation, this would show a file picker
+ await _dialogService.ShowMessageAsync(
+ "Add Existing Store",
+ "File picker for existing stores not yet implemented.");
+ }
+
+ ///
+ /// Unlocks the currently selected secure store.
+ ///
+ private async Task UnlockStoreAsync()
+ {
+ if (_selectedStoreNode == null || string.IsNullOrEmpty(_selectedStoreNode.StorePath))
+ return;
+
+ if (_dialogService == null)
+ {
+ _logger?.LogWarning("Dialog service is not available");
+ return;
+ }
+
+ // Note: In a full implementation, this would show the UnlockStoreDialog
+ // For now, we simulate with a simple confirmation
+ var confirmed = await _dialogService.ShowConfirmationAsync(
+ "Unlock Store",
+ $"Enter credentials to unlock '{_selectedStoreNode.Name}'.\n\n(Dialog not yet implemented - this is a placeholder)");
+
+ if (!confirmed)
+ return;
+
+ // In a real implementation, we would get the key file path or password from the dialog
+ // For now, we look for a .key file with the same name
+ var keyFilePath = Path.ChangeExtension(_selectedStoreNode.StorePath, ".key");
+ if (!File.Exists(keyFilePath))
+ {
+ // Try alternate pattern: storename.secrets.key
+ keyFilePath = _selectedStoreNode.StorePath.Replace(".secrets.json", ".secrets.key");
+ }
+
+ if (!File.Exists(keyFilePath))
+ {
+ await _dialogService.ShowMessageAsync(
+ SecureStoreStrings.ErrorTitle,
+ $"Key file not found. Expected at:\n{keyFilePath}");
+ return;
+ }
+
+ try
+ {
+ _secureStoreManager.OpenStore(_selectedStoreNode.StorePath, keyFilePath);
+ _selectedStoreNode.IsUnlocked = true;
+ _openStores[_selectedStoreNode.StorePath] = _selectedStoreNode;
+
+ // Populate secret children
+ RefreshStoreChildren(_selectedStoreNode);
+ _selectedStoreNode.IsExpanded = true;
+
+ // Refresh the form view
+ OnSelectedNodeChanged();
+
+ _logger?.LogInformation("Store unlocked: {StorePath}", _selectedStoreNode.StorePath);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to unlock store: {StorePath}", _selectedStoreNode.StorePath);
+ await _dialogService.ShowMessageAsync(
+ SecureStoreStrings.ErrorTitle,
+ string.Format(SecureStoreStrings.FailedToOpenStoreFormat, ex.Message));
+ }
+ }
+
+ ///
+ /// Locks the currently selected secure store.
+ ///
+ private void LockStore()
+ {
+ if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked)
+ return;
+
+ LockStoreInternal(_selectedStoreNode);
+ OnSelectedNodeChanged();
+ }
+
+ ///
+ /// Internal method to lock a store and clean up.
+ ///
+ private void LockStoreInternal(TreeNodeViewModel storeNode)
+ {
+ if (storeNode.StorePath != null)
+ {
+ _openStores.Remove(storeNode.StorePath);
+ }
+
+ // Check if this is the currently open store in the manager
+ if (_secureStoreManager.IsStoreOpen &&
+ _secureStoreManager.CurrentStorePath == storeNode.StorePath)
+ {
+ _secureStoreManager.CloseStore();
+ }
+
+ storeNode.IsUnlocked = false;
+ storeNode.Children.Clear();
+
+ _logger?.LogInformation("Store locked: {StorePath}", storeNode.StorePath);
+ }
+
+ ///
+ /// Saves the currently open secure store.
+ ///
+ private async Task SaveStoreAsync()
+ {
+ if (!_secureStoreManager.IsStoreOpen)
+ return;
+
+ try
+ {
+ _secureStoreManager.Save();
+
+ // Refresh the form to update the HasUnsavedChanges state
+ OnSelectedNodeChanged();
+ RaiseSecureStoreCommandsCanExecuteChanged();
+
+ _logger?.LogInformation("Store saved: {StorePath}", _secureStoreManager.CurrentStorePath);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to save store");
+ if (_dialogService != null)
+ {
+ await _dialogService.ShowMessageAsync(
+ SecureStoreStrings.ErrorTitle,
+ string.Format(SecureStoreStrings.FailedToSaveStoreFormat, ex.Message));
+ }
+ }
+ }
+
+ ///
+ /// Locks all open secure stores.
+ ///
+ private void LockAllStores()
+ {
+ var openStoresCopy = _openStores.Values.ToList();
+ foreach (var storeNode in openStoresCopy)
+ {
+ LockStoreInternal(storeNode);
+ }
+
+ OnSelectedNodeChanged();
+ _logger?.LogInformation("All stores locked");
+ }
+
+ ///
+ /// Generates a new key file.
+ ///
+ private async Task GenerateKeyFileAsync()
+ {
+ if (_dialogService == null)
+ {
+ _logger?.LogWarning("Dialog service is not available");
+ return;
+ }
+
+ // Note: In a full implementation, this would show a save file dialog
+ await _dialogService.ShowMessageAsync(
+ SecureStoreStrings.GenerateKeyFileTitle,
+ "Key file generation dialog not yet implemented.");
+ }
+
+ ///
+ /// Adds a new secret to the current store.
+ ///
+ private async Task AddSecretAsync()
+ {
+ if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked)
+ return;
+
+ if (_dialogService == null)
+ {
+ _logger?.LogWarning("Dialog service is not available");
+ return;
+ }
+
+ // Note: In a full implementation, this would show the SecretEditDialog
+ // For demonstration, we'll show a message
+ await _dialogService.ShowMessageAsync(
+ "Add Secret",
+ "Secret edit dialog not yet implemented.\n\nTo add secrets, the SecretEditDialog must be wired up.");
+ }
+
+ ///
+ /// Deletes the currently selected secret.
+ ///
+ private async Task DeleteSecretAsync()
+ {
+ if (_selectedNode == null ||
+ _selectedNode.NodeType != TreeNodeType.Secret ||
+ string.IsNullOrEmpty(_selectedNode.SecretKey))
+ return;
+
+ if (_dialogService == null)
+ {
+ _logger?.LogWarning("Dialog service is not available");
+ return;
+ }
+
+ var confirmed = await _dialogService.ShowConfirmationAsync(
+ SecureStoreStrings.ConfirmDeleteTitle,
+ string.Format(SecureStoreStrings.ConfirmDeleteFormat, _selectedNode.SecretKey));
+
+ if (!confirmed)
+ return;
+
+ try
+ {
+ _secureStoreManager.RemoveSecret(_selectedNode.SecretKey);
+
+ // Remove from tree
+ if (_selectedStoreNode != null)
+ {
+ _selectedStoreNode.Children.Remove(_selectedNode);
+
+ // Select the parent store node
+ SelectedNode = _selectedStoreNode;
+ }
+
+ _logger?.LogInformation("Secret deleted: {Key}", _selectedNode.SecretKey);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to delete secret: {Key}", _selectedNode.SecretKey);
+ await _dialogService.ShowMessageAsync(
+ SecureStoreStrings.ErrorTitle,
+ string.Format(SecureStoreStrings.FailedToDeleteSecretFormat, ex.Message));
+ }
+ }
+
+ ///
+ /// Refreshes the secret children of a store node.
+ ///
+ private void RefreshStoreChildren(TreeNodeViewModel storeNode)
+ {
+ storeNode.Children.Clear();
+
+ if (!_secureStoreManager.IsStoreOpen)
+ return;
+
+ try
+ {
+ var keys = _secureStoreManager.GetKeys();
+ foreach (var key in keys.OrderBy(k => k))
+ {
+ var secretNode = new TreeNodeViewModel(key, "key", TreeNodeType.Secret)
+ {
+ SecretKey = key,
+ SectionKey = key
+ };
+ storeNode.Children.Add(secretNode);
+ }
+
+ _logger?.LogDebug("Refreshed {Count} secrets for store", keys.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to refresh store children");
+ }
+ }
+
+ #endregion
}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs
index 87998e1..0a4415a 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs
@@ -6,7 +6,10 @@ public enum TreeNodeType
{
Folder,
SettingsSection,
- Pipeline
+ Pipeline,
+ SecureStoresFolder, // The "Secure Stores" folder
+ SecureStore, // Individual store files
+ Secret // Individual secrets within a store
}
public enum ValidationState
@@ -26,6 +29,7 @@ public class TreeNodeViewModel : ViewModelBase
private bool _isExpanded;
private bool _isSelected;
private ValidationState _validationState = ValidationState.Unknown;
+ private bool _isUnlocked;
///
/// Gets the name of the tree node.
@@ -47,6 +51,45 @@ public class TreeNodeViewModel : ViewModelBase
///
public string? SectionKey { get; init; }
+ ///
+ /// Gets or sets whether this secure store is currently unlocked.
+ /// Only applicable for SecureStore node types.
+ ///
+ public bool IsUnlocked
+ {
+ get => _isUnlocked;
+ set
+ {
+ if (SetProperty(ref _isUnlocked, value))
+ {
+ OnPropertyChanged(nameof(LockIcon));
+ OnPropertyChanged(nameof(IsLocked));
+ }
+ }
+ }
+
+ ///
+ /// Gets whether this secure store is locked.
+ ///
+ public bool IsLocked => !IsUnlocked;
+
+ ///
+ /// Gets the lock icon for secure store nodes.
+ ///
+ public string LockIcon => IsUnlocked ? "🔓" : "🔒";
+
+ ///
+ /// Gets or sets the full path to the secure store file.
+ /// Only applicable for SecureStore node types.
+ ///
+ public string? StorePath { get; init; }
+
+ ///
+ /// Gets or sets the secret key name.
+ /// Only applicable for Secret node types.
+ ///
+ public string? SecretKey { get; init; }
+
///
/// Gets the collection of child nodes.
///
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/NewStoreDialog.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/NewStoreDialog.axaml
new file mode 100644
index 0000000..697676f
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/NewStoreDialog.axaml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/NewStoreDialog.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/NewStoreDialog.axaml.cs
new file mode 100644
index 0000000..b890669
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/NewStoreDialog.axaml.cs
@@ -0,0 +1,129 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using JdeScoping.ConfigManager.Constants;
+using JdeScoping.ConfigManager.Services.SecureStore;
+using JdeScoping.ConfigManager.ViewModels.Dialogs;
+using MsBox.Avalonia;
+using MsBox.Avalonia.Enums;
+
+namespace JdeScoping.ConfigManager.Views.Dialogs;
+
+///
+/// Dialog for creating a new secure store.
+///
+public partial class NewStoreDialog : Window
+{
+ private readonly ISecureStoreManager _secureStoreManager;
+
+ ///
+ /// Gets the view model for this dialog.
+ ///
+ public NewStoreDialogViewModel ViewModel => (NewStoreDialogViewModel)DataContext!;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public NewStoreDialog() : this(new SecureStoreManager())
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified secure store manager.
+ ///
+ /// The secure store manager for key generation.
+ public NewStoreDialog(ISecureStoreManager secureStoreManager)
+ {
+ _secureStoreManager = secureStoreManager ?? throw new ArgumentNullException(nameof(secureStoreManager));
+ InitializeComponent();
+ DataContext = new NewStoreDialogViewModel();
+ Loaded += NewStoreDialog_Loaded;
+ }
+
+ private void NewStoreDialog_Loaded(object? sender, RoutedEventArgs e)
+ {
+ ViewModel.OnShowSaveFileDialog += ShowSaveFileDialogAsync;
+ ViewModel.OnGenerateKeyFile += GenerateKeyFileAsync;
+ }
+
+ private async Task 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(SecureStoreFileExtensions.AllFilesTypeName) { Patterns = new[] { SecureStoreFileExtensions.AllFilesPattern } }
+ }
+ });
+
+ return file?.Path.LocalPath;
+ }
+
+ private async Task GenerateKeyFileAsync(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(SecureStoreFileExtensions.AllFilesTypeName) { Patterns = new[] { SecureStoreFileExtensions.AllFilesPattern } }
+ }
+ });
+
+ if (file == null)
+ return null;
+
+ var path = file.Path.LocalPath;
+
+ try
+ {
+ // Generate a new key file using the local SecureStoreManager
+ _secureStoreManager.GenerateKeyFile(path);
+
+ var box = MessageBoxManager.GetMessageBoxStandard(
+ SecureStoreStrings.KeyGeneratedTitle,
+ string.Format(SecureStoreStrings.KeyFileGeneratedFormat, path),
+ ButtonEnum.Ok,
+ MsBox.Avalonia.Enums.Icon.Info);
+ await box.ShowWindowDialogAsync(this);
+
+ return path;
+ }
+ catch (Exception ex)
+ {
+ var box = MessageBoxManager.GetMessageBoxStandard(
+ SecureStoreStrings.ErrorTitle,
+ string.Format(SecureStoreStrings.FailedToGenerateKeyFormat, ex.Message),
+ ButtonEnum.Ok,
+ MsBox.Avalonia.Enums.Icon.Error);
+ await box.ShowWindowDialogAsync(this);
+ return null;
+ }
+ }
+
+ private async void CreateButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (!ViewModel.IsValid)
+ {
+ var box = MessageBoxManager.GetMessageBoxStandard(
+ SecureStoreStrings.ValidationErrorTitle,
+ ViewModel.ValidationError ?? SecureStoreStrings.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);
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/SecretEditDialog.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/SecretEditDialog.axaml
new file mode 100644
index 0000000..a2984af
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/SecretEditDialog.axaml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/SecretEditDialog.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/SecretEditDialog.axaml.cs
new file mode 100644
index 0000000..ef82a58
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/SecretEditDialog.axaml.cs
@@ -0,0 +1,60 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using JdeScoping.ConfigManager.Constants;
+using JdeScoping.ConfigManager.ViewModels.Dialogs;
+using MsBox.Avalonia;
+using MsBox.Avalonia.Enums;
+
+namespace JdeScoping.ConfigManager.Views.Dialogs;
+
+///
+/// Dialog for adding or editing a secret in the secure store.
+///
+public partial class SecretEditDialog : Window
+{
+ ///
+ /// Gets the view model for this dialog.
+ ///
+ public SecretEditDialogViewModel ViewModel => (SecretEditDialogViewModel)DataContext!;
+
+ ///
+ /// Initializes a new instance of the class for creating a new secret.
+ ///
+ public SecretEditDialog()
+ {
+ InitializeComponent();
+ DataContext = new SecretEditDialogViewModel();
+ }
+
+ ///
+ /// Initializes a new instance of the class for editing an existing secret.
+ ///
+ /// The secret key.
+ /// The secret value.
+ 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(
+ SecureStoreStrings.ValidationErrorTitle,
+ ViewModel.ValidationError ?? SecureStoreStrings.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);
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/UnlockStoreDialog.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/UnlockStoreDialog.axaml
new file mode 100644
index 0000000..5753f71
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/UnlockStoreDialog.axaml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/UnlockStoreDialog.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/UnlockStoreDialog.axaml.cs
new file mode 100644
index 0000000..f3efc29
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/UnlockStoreDialog.axaml.cs
@@ -0,0 +1,80 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using JdeScoping.ConfigManager.Constants;
+using JdeScoping.ConfigManager.ViewModels.Dialogs;
+using MsBox.Avalonia;
+using MsBox.Avalonia.Enums;
+
+namespace JdeScoping.ConfigManager.Views.Dialogs;
+
+///
+/// Dialog for unlocking an existing secure store.
+///
+public partial class UnlockStoreDialog : Window
+{
+ ///
+ /// Gets the view model for this dialog.
+ ///
+ public UnlockStoreDialogViewModel ViewModel => (UnlockStoreDialogViewModel)DataContext!;
+
+ ///
+ /// Design-time constructor. Required for XAML previewer.
+ ///
+ public UnlockStoreDialog() : this(string.Empty)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The path to the store file to unlock.
+ public UnlockStoreDialog(string storePath)
+ {
+ InitializeComponent();
+ DataContext = new UnlockStoreDialogViewModel(storePath);
+ Loaded += UnlockStoreDialog_Loaded;
+ }
+
+ private void UnlockStoreDialog_Loaded(object? sender, RoutedEventArgs e)
+ {
+ ViewModel.OnShowOpenFileDialog += ShowOpenFileDialogAsync;
+ }
+
+ private async Task 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(SecureStoreFileExtensions.AllFilesTypeName) { Patterns = new[] { SecureStoreFileExtensions.AllFilesPattern } }
+ }
+ });
+
+ return files.Count > 0 ? files[0].Path.LocalPath : null;
+ }
+
+ private async void UnlockButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (!ViewModel.IsValid)
+ {
+ var box = MessageBoxManager.GetMessageBoxStandard(
+ SecureStoreStrings.ValidationErrorTitle,
+ ViewModel.ValidationError ?? SecureStoreStrings.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);
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecretFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecretFormView.axaml
new file mode 100644
index 0000000..10f6dbb
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecretFormView.axaml
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecretFormView.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecretFormView.axaml.cs
new file mode 100644
index 0000000..4155096
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecretFormView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace JdeScoping.ConfigManager.Views.Forms;
+
+public partial class SecretFormView : UserControl
+{
+ public SecretFormView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml
new file mode 100644
index 0000000..8d0e5d6
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs
new file mode 100644
index 0000000..140275e
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace JdeScoping.ConfigManager.Views.Forms;
+
+public partial class SecureStoreLockedFormView : UserControl
+{
+ public SecureStoreLockedFormView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml
new file mode 100644
index 0000000..4d5aec3
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs
new file mode 100644
index 0000000..8b9e3d1
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace JdeScoping.ConfigManager.Views.Forms;
+
+public partial class SecureStoreUnlockedFormView : UserControl
+{
+ public SecureStoreUnlockedFormView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml
index 4d5d371..918dbbf 100644
--- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml
@@ -36,6 +36,15 @@
+
+
+
+
+
+
+
+
+
@@ -57,6 +66,35 @@
+
@@ -74,6 +112,9 @@
+
+
+
@@ -123,6 +164,17 @@
SelectedItem="{Binding SelectedNode}"
Background="Transparent"
Margin="8">
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj b/NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj
index 6fbb49e..987dbc7 100644
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj
@@ -12,6 +12,8 @@
+
+
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs
new file mode 100644
index 0000000..21dc9b7
--- /dev/null
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs
@@ -0,0 +1,338 @@
+using System.IO;
+using JdeScoping.ConfigManager.Services.SecureStore;
+
+namespace JdeScoping.ConfigManager.Tests.Services.SecureStore;
+
+public class SecureStoreManagerTests : IDisposable
+{
+ private readonly string _testDirectory;
+ private readonly SecureStoreManager _sut;
+
+ public SecureStoreManagerTests()
+ {
+ _testDirectory = Path.Combine(Path.GetTempPath(), $"SecureStoreTests_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDirectory);
+ _sut = new SecureStoreManager();
+ }
+
+ public void Dispose()
+ {
+ _sut.Dispose();
+ if (Directory.Exists(_testDirectory))
+ {
+ Directory.Delete(_testDirectory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void IsStoreOpen_WhenNoStoreOpen_ReturnsFalse()
+ {
+ _sut.IsStoreOpen.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void CurrentStorePath_WhenNoStoreOpen_ReturnsNull()
+ {
+ _sut.CurrentStorePath.ShouldBeNull();
+ }
+
+ [Fact]
+ public void HasUnsavedChanges_WhenNoStoreOpen_ReturnsFalse()
+ {
+ _sut.HasUnsavedChanges.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void CreateStore_WithKeyFile_CreatesStoreAndKeyFile()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+
+ // Act
+ _sut.CreateStore(storePath, keyPath);
+
+ // Assert
+ _sut.IsStoreOpen.ShouldBeTrue();
+ _sut.CurrentStorePath.ShouldBe(storePath);
+ File.Exists(storePath).ShouldBeTrue();
+ File.Exists(keyPath).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void CreateStoreWithPassword_CreatesStore()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+
+ // Act
+ _sut.CreateStoreWithPassword(storePath, "testpassword123");
+
+ // Assert
+ _sut.IsStoreOpen.ShouldBeTrue();
+ _sut.CurrentStorePath.ShouldBe(storePath);
+ File.Exists(storePath).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void CreateStoreWithPassword_WithEmptyPassword_ThrowsArgumentException()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+
+ // Act & Assert
+ Should.Throw(() => _sut.CreateStoreWithPassword(storePath, ""));
+ }
+
+ [Fact]
+ public void OpenStore_WithValidKeyFile_OpensStore()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+ _sut.CloseStore();
+
+ // Act
+ _sut.OpenStore(storePath, keyPath);
+
+ // Assert
+ _sut.IsStoreOpen.ShouldBeTrue();
+ _sut.CurrentStorePath.ShouldBe(storePath);
+ }
+
+ [Fact]
+ public void OpenStore_WithNonExistentStore_ThrowsFileNotFoundException()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "nonexistent.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+
+ // Act & Assert
+ Should.Throw(() => _sut.OpenStore(storePath, keyPath));
+ }
+
+ [Fact]
+ public void OpenStoreWithPassword_OpensStore()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var password = "testpassword123";
+ _sut.CreateStoreWithPassword(storePath, password);
+ _sut.CloseStore();
+
+ // Act
+ _sut.OpenStoreWithPassword(storePath, password);
+
+ // Assert
+ _sut.IsStoreOpen.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void CloseStore_ClosesOpenStore()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+
+ // Act
+ _sut.CloseStore();
+
+ // Assert
+ _sut.IsStoreOpen.ShouldBeFalse();
+ _sut.CurrentStorePath.ShouldBeNull();
+ }
+
+ [Fact]
+ public void SetSecret_AddsSecretAndMarksUnsaved()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+ _sut.Save(); // Save to clear unsaved flag
+
+ // Act
+ _sut.SetSecret("testKey", "testValue");
+
+ // Assert
+ _sut.HasUnsavedChanges.ShouldBeTrue();
+ _sut.GetKeys().ShouldContain("testKey");
+ }
+
+ [Fact]
+ public void GetSecret_ReturnsCorrectValue()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+ _sut.SetSecret("testKey", "testValue");
+
+ // Act
+ var value = _sut.GetSecret("testKey");
+
+ // Assert
+ value.ShouldBe("testValue");
+ }
+
+ [Fact]
+ public void GetSecret_WhenKeyNotFound_ThrowsKeyNotFoundException()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+
+ // Act & Assert
+ Should.Throw(() => _sut.GetSecret("nonexistent"));
+ }
+
+ [Fact]
+ public void RemoveSecret_RemovesSecretAndMarksUnsaved()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+ _sut.SetSecret("testKey", "testValue");
+ _sut.Save();
+
+ // Act
+ _sut.RemoveSecret("testKey");
+
+ // Assert
+ _sut.HasUnsavedChanges.ShouldBeTrue();
+ _sut.GetKeys().ShouldNotContain("testKey");
+ }
+
+ [Fact]
+ public void RemoveSecret_WhenKeyNotFound_ThrowsKeyNotFoundException()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+
+ // Act & Assert
+ Should.Throw(() => _sut.RemoveSecret("nonexistent"));
+ }
+
+ [Fact]
+ public void Save_PersistsSecretsToStore()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+ _sut.SetSecret("testKey", "testValue");
+
+ // Act
+ _sut.Save();
+ _sut.CloseStore();
+ _sut.OpenStore(storePath, keyPath);
+
+ // Assert
+ _sut.GetKeys().ShouldContain("testKey");
+ _sut.GetSecret("testKey").ShouldBe("testValue");
+ }
+
+ [Fact]
+ public void Save_ClearsUnsavedChangesFlag()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+ _sut.SetSecret("testKey", "testValue");
+ _sut.HasUnsavedChanges.ShouldBeTrue();
+
+ // Act
+ _sut.Save();
+
+ // Assert
+ _sut.HasUnsavedChanges.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void GetKeys_ReturnsAllSecretKeys()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ _sut.CreateStore(storePath, keyPath);
+ _sut.SetSecret("key1", "value1");
+ _sut.SetSecret("key2", "value2");
+ _sut.SetSecret("key3", "value3");
+
+ // Act
+ var keys = _sut.GetKeys();
+
+ // Assert
+ keys.Count.ShouldBe(3);
+ keys.ShouldContain("key1");
+ keys.ShouldContain("key2");
+ keys.ShouldContain("key3");
+ }
+
+ [Fact]
+ public void GenerateKeyFile_CreatesNewKeyFile()
+ {
+ // Arrange
+ var keyPath = Path.Combine(_testDirectory, "generated.key");
+
+ // Act
+ _sut.GenerateKeyFile(keyPath);
+
+ // Assert
+ File.Exists(keyPath).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ExportKey_WhenNoStoreOpen_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var keyPath = Path.Combine(_testDirectory, "export.key");
+
+ // Act & Assert
+ Should.Throw(() => _sut.ExportKey(keyPath));
+ }
+
+ [Fact]
+ public void ExportKey_WhenStoreOpen_ExportsKey()
+ {
+ // Arrange
+ var storePath = Path.Combine(_testDirectory, "test.json");
+ var keyPath = Path.Combine(_testDirectory, "test.key");
+ var exportPath = Path.Combine(_testDirectory, "export.key");
+ _sut.CreateStore(storePath, keyPath);
+
+ // Act
+ _sut.ExportKey(exportPath);
+
+ // Assert
+ File.Exists(exportPath).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void GetKeys_WhenNoStoreOpen_ThrowsInvalidOperationException()
+ {
+ // Act & Assert
+ Should.Throw(() => _sut.GetKeys());
+ }
+
+ [Fact]
+ public void SetSecret_WhenNoStoreOpen_ThrowsInvalidOperationException()
+ {
+ // Act & Assert
+ Should.Throw(() => _sut.SetSecret("key", "value"));
+ }
+
+ [Fact]
+ public void GetSecret_WhenNoStoreOpen_ThrowsInvalidOperationException()
+ {
+ // Act & Assert
+ Should.Throw(() => _sut.GetSecret("key"));
+ }
+}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/NewStoreDialogViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/NewStoreDialogViewModelTests.cs
new file mode 100644
index 0000000..37a3f86
--- /dev/null
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/NewStoreDialogViewModelTests.cs
@@ -0,0 +1,399 @@
+using JdeScoping.ConfigManager.Constants;
+using JdeScoping.ConfigManager.ViewModels.Dialogs;
+
+namespace JdeScoping.ConfigManager.Tests.ViewModels.Dialogs;
+
+public class NewStoreDialogViewModelTests
+{
+ [Fact]
+ public void Constructor_DefaultsToUseKeyFile()
+ {
+ // Arrange & Act
+ var sut = new NewStoreDialogViewModel();
+
+ // Assert
+ sut.UseKeyFile.ShouldBeTrue();
+ sut.UsePassword.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void StorePath_WhenEmpty_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "",
+ KeyFilePath = "/path/to/key.key"
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void StorePath_WhenEmpty_ValidationErrorReturnsStorePathRequired()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "",
+ KeyFilePath = "/path/to/key.key"
+ };
+
+ // Act & Assert
+ sut.ValidationError.ShouldBe(SecureStoreStrings.StorePathRequired);
+ }
+
+ [Fact]
+ public void StorePath_WhenWhitespace_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = " ",
+ KeyFilePath = "/path/to/key.key"
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ sut.ValidationError.ShouldBe(SecureStoreStrings.StorePathRequired);
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenRequiredAndEmpty_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure",
+ UseKeyFile = true,
+ KeyFilePath = ""
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenRequiredAndEmpty_ValidationErrorReturnsKeyFilePathRequired()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure",
+ UseKeyFile = true,
+ KeyFilePath = ""
+ };
+
+ // Act & Assert
+ sut.ValidationError.ShouldBe(SecureStoreStrings.KeyFilePathRequired);
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenRequiredAndProvided_IsValidReturnsTrue()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure",
+ UseKeyFile = true,
+ KeyFilePath = "/path/to/key.key"
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeTrue();
+ sut.ValidationError.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Password_WhenRequiredAndEmpty_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure",
+ UsePassword = true,
+ Password = ""
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Password_WhenRequiredAndEmpty_ValidationErrorReturnsPasswordRequired()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure",
+ UsePassword = true,
+ Password = ""
+ };
+
+ // Act & Assert
+ sut.ValidationError.ShouldBe(SecureStoreStrings.PasswordRequired);
+ }
+
+ [Fact]
+ public void ConfirmPassword_WhenRequiredAndDoesNotMatch_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure",
+ UsePassword = true,
+ Password = "password123",
+ ConfirmPassword = "differentPassword"
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ConfirmPassword_WhenRequiredAndDoesNotMatch_ValidationErrorReturnsPasswordsDoNotMatch()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure",
+ UsePassword = true,
+ Password = "password123",
+ ConfirmPassword = "differentPassword"
+ };
+
+ // Act & Assert
+ sut.ValidationError.ShouldBe(SecureStoreStrings.PasswordsDoNotMatch);
+ }
+
+ [Fact]
+ public void Password_WhenRequiredAndMatchesConfirmPassword_IsValidReturnsTrue()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure",
+ UsePassword = true,
+ Password = "password123",
+ ConfirmPassword = "password123"
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeTrue();
+ sut.ValidationError.ShouldBeNull();
+ }
+
+ [Fact]
+ public void UseKeyFile_WhenSetToTrue_SetsUsePasswordToFalse()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ UsePassword = true
+ };
+
+ // Act
+ sut.UseKeyFile = true;
+
+ // Assert
+ sut.UseKeyFile.ShouldBeTrue();
+ sut.UsePassword.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void UsePassword_WhenSetToTrue_SetsUseKeyFileToFalse()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ UseKeyFile = true
+ };
+
+ // Act
+ sut.UsePassword = true;
+
+ // Assert
+ sut.UsePassword.ShouldBeTrue();
+ sut.UseKeyFile.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsValid_WhenNeitherKeyFileNorPasswordSelected_ReturnsFalse()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel
+ {
+ StorePath = "/path/to/store.secure"
+ };
+ // Manually set both to false (shouldn't happen in UI, but test edge case)
+ sut.UseKeyFile = false;
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void StorePath_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel();
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(NewStoreDialogViewModel.StorePath))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.StorePath = "/new/path";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void StorePath_WhenChanged_RaisesIsValidPropertyChanged()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel();
+ var isValidPropertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(NewStoreDialogViewModel.IsValid))
+ isValidPropertyChangedRaised = true;
+ };
+
+ // Act
+ sut.StorePath = "/new/path";
+
+ // Assert
+ isValidPropertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void StorePath_WhenChanged_RaisesValidationErrorPropertyChanged()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel();
+ var validationErrorPropertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(NewStoreDialogViewModel.ValidationError))
+ validationErrorPropertyChangedRaised = true;
+ };
+
+ // Act
+ sut.StorePath = "/new/path";
+
+ // Assert
+ validationErrorPropertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel();
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(NewStoreDialogViewModel.KeyFilePath))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.KeyFilePath = "/new/key/path";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Password_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel();
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(NewStoreDialogViewModel.Password))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.Password = "newpassword";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ConfirmPassword_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel();
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(NewStoreDialogViewModel.ConfirmPassword))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.ConfirmPassword = "newpassword";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void UseKeyFile_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel { UseKeyFile = false };
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(NewStoreDialogViewModel.UseKeyFile))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.UseKeyFile = true;
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void UsePassword_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new NewStoreDialogViewModel();
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(NewStoreDialogViewModel.UsePassword))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.UsePassword = true;
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Commands_AreInitialized()
+ {
+ // Arrange & Act
+ var sut = new NewStoreDialogViewModel();
+
+ // Assert
+ sut.BrowseStorePathCommand.ShouldNotBeNull();
+ sut.BrowseKeyFilePathCommand.ShouldNotBeNull();
+ sut.GenerateKeyFileCommand.ShouldNotBeNull();
+ }
+}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/SecretEditDialogViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/SecretEditDialogViewModelTests.cs
new file mode 100644
index 0000000..d9eb4b2
--- /dev/null
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/SecretEditDialogViewModelTests.cs
@@ -0,0 +1,343 @@
+using JdeScoping.ConfigManager.Constants;
+using JdeScoping.ConfigManager.ViewModels.Dialogs;
+
+namespace JdeScoping.ConfigManager.Tests.ViewModels.Dialogs;
+
+public class SecretEditDialogViewModelTests
+{
+ [Fact]
+ public void DefaultConstructor_SetsIsNewSecretToTrue()
+ {
+ // Arrange & Act
+ var sut = new SecretEditDialogViewModel();
+
+ // Assert
+ sut.IsNewSecret.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void DefaultConstructor_SetsKeyToEmpty()
+ {
+ // Arrange & Act
+ var sut = new SecretEditDialogViewModel();
+
+ // Assert
+ sut.Key.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void DefaultConstructor_SetsValueToEmpty()
+ {
+ // Arrange & Act
+ var sut = new SecretEditDialogViewModel();
+
+ // Assert
+ sut.Value.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void EditConstructor_SetsKeyFromParameter()
+ {
+ // Arrange & Act
+ var sut = new SecretEditDialogViewModel("myKey", "myValue");
+
+ // Assert
+ sut.Key.ShouldBe("myKey");
+ }
+
+ [Fact]
+ public void EditConstructor_SetsValueFromParameter()
+ {
+ // Arrange & Act
+ var sut = new SecretEditDialogViewModel("myKey", "myValue");
+
+ // Assert
+ sut.Value.ShouldBe("myValue");
+ }
+
+ [Fact]
+ public void EditConstructor_SetsIsNewSecretToFalse()
+ {
+ // Arrange & Act
+ var sut = new SecretEditDialogViewModel("myKey", "myValue");
+
+ // Assert
+ sut.IsNewSecret.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Key_WhenEmpty_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel
+ {
+ Key = ""
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Key_WhenEmpty_ValidationErrorReturnsKeyRequired()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel
+ {
+ Key = ""
+ };
+
+ // Act & Assert
+ sut.ValidationError.ShouldBe(SecureStoreStrings.KeyRequired);
+ }
+
+ [Fact]
+ public void Key_WhenWhitespace_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel
+ {
+ Key = " "
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ sut.ValidationError.ShouldBe(SecureStoreStrings.KeyRequired);
+ }
+
+ [Fact]
+ public void Key_WhenProvided_IsValidReturnsTrue()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel
+ {
+ Key = "validKey"
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeTrue();
+ sut.ValidationError.ShouldBeNull();
+ }
+
+ [Fact]
+ public void IsKeyEditable_WhenIsNewSecretIsTrue_ReturnsTrue()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+
+ // Act & Assert
+ sut.IsKeyEditable.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsKeyEditable_WhenIsNewSecretIsFalse_ReturnsFalse()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel("existingKey", "existingValue");
+
+ // Act & Assert
+ sut.IsKeyEditable.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void DialogTitle_WhenIsNewSecretIsTrue_ReturnsAddSecret()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+
+ // Act & Assert
+ sut.DialogTitle.ShouldBe("Add Secret");
+ }
+
+ [Fact]
+ public void DialogTitle_WhenIsNewSecretIsFalse_ReturnsEditSecret()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel("existingKey", "existingValue");
+
+ // Act & Assert
+ sut.DialogTitle.ShouldBe("Edit Secret");
+ }
+
+ [Fact]
+ public void IsNewSecret_WhenChanged_RaisesIsKeyEditablePropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+ var isKeyEditablePropertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretEditDialogViewModel.IsKeyEditable))
+ isKeyEditablePropertyChangedRaised = true;
+ };
+
+ // Act
+ sut.IsNewSecret = false;
+
+ // Assert
+ isKeyEditablePropertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsNewSecret_WhenChanged_RaisesDialogTitlePropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+ var dialogTitlePropertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretEditDialogViewModel.DialogTitle))
+ dialogTitlePropertyChangedRaised = true;
+ };
+
+ // Act
+ sut.IsNewSecret = false;
+
+ // Assert
+ dialogTitlePropertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Key_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretEditDialogViewModel.Key))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.Key = "newKey";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Key_WhenChanged_RaisesIsValidPropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+ var isValidPropertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretEditDialogViewModel.IsValid))
+ isValidPropertyChangedRaised = true;
+ };
+
+ // Act
+ sut.Key = "newKey";
+
+ // Assert
+ isValidPropertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Key_WhenChanged_RaisesValidationErrorPropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+ var validationErrorPropertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretEditDialogViewModel.ValidationError))
+ validationErrorPropertyChangedRaised = true;
+ };
+
+ // Act
+ sut.Key = "newKey";
+
+ // Assert
+ validationErrorPropertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Value_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretEditDialogViewModel.Value))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.Value = "newValue";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Value_CanBeEmpty_IsValidStillReturnsTrue()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel
+ {
+ Key = "validKey",
+ Value = ""
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeTrue();
+ sut.ValidationError.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Value_CanBeNull_IsValidStillReturnsTrue()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel
+ {
+ Key = "validKey",
+ Value = null!
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeTrue();
+ sut.ValidationError.ShouldBeNull();
+ }
+
+ [Fact]
+ public void IsNewSecret_WhenSetToSameValue_DoesNotRaisePropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel();
+ var propertyChangedCount = 0;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretEditDialogViewModel.IsNewSecret))
+ propertyChangedCount++;
+ };
+
+ // Act
+ sut.IsNewSecret = true; // Same value as default
+
+ // Assert
+ propertyChangedCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Key_WhenSetToSameValue_DoesNotRaisePropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretEditDialogViewModel { Key = "testKey" };
+ var propertyChangedCount = 0;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretEditDialogViewModel.Key))
+ propertyChangedCount++;
+ };
+
+ // Act
+ sut.Key = "testKey"; // Same value
+
+ // Assert
+ propertyChangedCount.ShouldBe(0);
+ }
+}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/UnlockStoreDialogViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/UnlockStoreDialogViewModelTests.cs
new file mode 100644
index 0000000..27528ec
--- /dev/null
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Dialogs/UnlockStoreDialogViewModelTests.cs
@@ -0,0 +1,347 @@
+using JdeScoping.ConfigManager.Constants;
+using JdeScoping.ConfigManager.ViewModels.Dialogs;
+
+namespace JdeScoping.ConfigManager.Tests.ViewModels.Dialogs;
+
+public class UnlockStoreDialogViewModelTests
+{
+ [Fact]
+ public void Constructor_SetsStorePathFromParameter()
+ {
+ // Arrange
+ var storePath = "/path/to/store.secure";
+
+ // Act
+ var sut = new UnlockStoreDialogViewModel(storePath);
+
+ // Assert
+ sut.StorePath.ShouldBe(storePath);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullStorePath()
+ {
+ // Act & Assert
+ Should.Throw(() => new UnlockStoreDialogViewModel(null!));
+ }
+
+ [Fact]
+ public void Constructor_DefaultsToUseKeyFile()
+ {
+ // Arrange & Act
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+
+ // Assert
+ sut.UseKeyFile.ShouldBeTrue();
+ sut.UsePassword.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void StorePath_IsReadOnly()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+
+ // Assert - StorePath property has no setter, verified by checking it returns what was passed
+ sut.StorePath.ShouldBe("/path/to/store.secure");
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenRequiredAndEmpty_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UseKeyFile = true,
+ KeyFilePath = ""
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenRequiredAndEmpty_ValidationErrorReturnsKeyFilePathRequired()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UseKeyFile = true,
+ KeyFilePath = ""
+ };
+
+ // Act & Assert
+ sut.ValidationError.ShouldBe(SecureStoreStrings.KeyFilePathRequired);
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenRequiredAndWhitespace_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UseKeyFile = true,
+ KeyFilePath = " "
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ sut.ValidationError.ShouldBe(SecureStoreStrings.KeyFilePathRequired);
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenRequiredAndFileDoesNotExist_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UseKeyFile = true,
+ KeyFilePath = "/nonexistent/path/to/key.key"
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenRequiredAndFileDoesNotExist_ValidationErrorReturnsKeyFileNotFound()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UseKeyFile = true,
+ KeyFilePath = "/nonexistent/path/to/key.key"
+ };
+
+ // Act & Assert
+ sut.ValidationError.ShouldBe(SecureStoreStrings.KeyFileNotFound);
+ }
+
+ [Fact]
+ public void Password_WhenRequiredAndEmpty_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UsePassword = true,
+ Password = ""
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Password_WhenRequiredAndEmpty_ValidationErrorReturnsPasswordRequired()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UsePassword = true,
+ Password = ""
+ };
+
+ // Act & Assert
+ sut.ValidationError.ShouldBe(SecureStoreStrings.PasswordRequired);
+ }
+
+ [Fact]
+ public void Password_WhenRequiredAndWhitespace_IsValidReturnsFalse()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UsePassword = true,
+ Password = " "
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ sut.ValidationError.ShouldBe(SecureStoreStrings.PasswordRequired);
+ }
+
+ [Fact]
+ public void Password_WhenRequiredAndProvided_IsValidReturnsTrue()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UsePassword = true,
+ Password = "password123"
+ };
+
+ // Act & Assert
+ sut.IsValid.ShouldBeTrue();
+ sut.ValidationError.ShouldBeNull();
+ }
+
+ [Fact]
+ public void UseKeyFile_WhenSetToTrue_SetsUsePasswordToFalse()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UsePassword = true
+ };
+
+ // Act
+ sut.UseKeyFile = true;
+
+ // Assert
+ sut.UseKeyFile.ShouldBeTrue();
+ sut.UsePassword.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void UsePassword_WhenSetToTrue_SetsUseKeyFileToFalse()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
+ {
+ UseKeyFile = true
+ };
+
+ // Act
+ sut.UsePassword = true;
+
+ // Assert
+ sut.UsePassword.ShouldBeTrue();
+ sut.UseKeyFile.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsValid_WhenNeitherKeyFileNorPasswordSelected_ReturnsFalse()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+ // Manually set both to false (shouldn't happen in UI, but test edge case)
+ sut.UseKeyFile = false;
+
+ // Act & Assert
+ sut.IsValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(UnlockStoreDialogViewModel.KeyFilePath))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.KeyFilePath = "/new/key/path";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenChanged_RaisesIsValidPropertyChanged()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+ var isValidPropertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(UnlockStoreDialogViewModel.IsValid))
+ isValidPropertyChangedRaised = true;
+ };
+
+ // Act
+ sut.KeyFilePath = "/new/key/path";
+
+ // Assert
+ isValidPropertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void KeyFilePath_WhenChanged_RaisesValidationErrorPropertyChanged()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+ var validationErrorPropertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(UnlockStoreDialogViewModel.ValidationError))
+ validationErrorPropertyChangedRaised = true;
+ };
+
+ // Act
+ sut.KeyFilePath = "/new/key/path";
+
+ // Assert
+ validationErrorPropertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Password_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(UnlockStoreDialogViewModel.Password))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.Password = "newpassword";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void UseKeyFile_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure") { UseKeyFile = false };
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(UnlockStoreDialogViewModel.UseKeyFile))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.UseKeyFile = true;
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void UsePassword_WhenChanged_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(UnlockStoreDialogViewModel.UsePassword))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.UsePassword = true;
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void BrowseKeyFilePathCommand_IsInitialized()
+ {
+ // Arrange & Act
+ var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
+
+ // Assert
+ sut.BrowseKeyFilePathCommand.ShouldNotBeNull();
+ }
+}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecretFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecretFormViewModelTests.cs
new file mode 100644
index 0000000..67fcc6b
--- /dev/null
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecretFormViewModelTests.cs
@@ -0,0 +1,412 @@
+using JdeScoping.ConfigManager.Services;
+using JdeScoping.ConfigManager.ViewModels.Forms;
+
+namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
+
+public class SecretFormViewModelTests
+{
+ private readonly IClipboardService _clipboardService;
+
+ public SecretFormViewModelTests()
+ {
+ _clipboardService = Substitute.For();
+ }
+
+ [Fact]
+ public void Constructor_SetsPropertiesCorrectly()
+ {
+ // Arrange & Act
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret-value-123",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Assert
+ sut.Key.ShouldBe("API_KEY");
+ sut.Value.ShouldBe("secret-value-123");
+ sut.IsValueVisible.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Constructor_WithNullValue_SetsEmptyString()
+ {
+ // Arrange & Act
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ null!,
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Assert
+ sut.Value.ShouldBe(string.Empty);
+ }
+
+ [Fact]
+ public void Value_Setter_InvokesCallback()
+ {
+ // Arrange
+ string? changedValue = null;
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "initial",
+ _clipboardService,
+ v => changedValue = v,
+ () => { });
+
+ // Act
+ sut.Value = "new-value";
+
+ // Assert
+ changedValue.ShouldBe("new-value");
+ }
+
+ [Fact]
+ public void Value_Setter_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "initial",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretFormViewModel.Value))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.Value = "new-value";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Value_Setter_RaisesDisplayValuePropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "initial",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ var displayValueChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretFormViewModel.DisplayValue))
+ displayValueChangedRaised = true;
+ };
+
+ // Act
+ sut.Value = "new-value";
+
+ // Assert
+ displayValueChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsValueVisible_Toggle_Works()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act & Assert - Initial state
+ sut.IsValueVisible.ShouldBeFalse();
+
+ // Act - Toggle on
+ sut.IsValueVisible = true;
+ sut.IsValueVisible.ShouldBeTrue();
+
+ // Act - Toggle off
+ sut.IsValueVisible = false;
+ sut.IsValueVisible.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsValueVisible_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretFormViewModel.IsValueVisible))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.IsValueVisible = true;
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsValueVisible_RaisesDisplayValueAndVisibilityButtonTextPropertyChanged()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ var displayValueChangedRaised = false;
+ var visibilityButtonTextChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecretFormViewModel.DisplayValue))
+ displayValueChangedRaised = true;
+ if (e.PropertyName == nameof(SecretFormViewModel.VisibilityButtonText))
+ visibilityButtonTextChangedRaised = true;
+ };
+
+ // Act
+ sut.IsValueVisible = true;
+
+ // Assert
+ displayValueChangedRaised.ShouldBeTrue();
+ visibilityButtonTextChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void DisplayValue_ShowsMaskedValue_WhenNotVisible()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret123",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act & Assert
+ sut.IsValueVisible.ShouldBeFalse();
+ sut.DisplayValue.ShouldBe("\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"); // 9 bullet points
+ }
+
+ [Fact]
+ public void DisplayValue_ShowsActualValue_WhenVisible()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret123",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act
+ sut.IsValueVisible = true;
+
+ // Assert
+ sut.DisplayValue.ShouldBe("secret123");
+ }
+
+ [Fact]
+ public void DisplayValue_LimitsMaskedLength_ToTwentyCharacters()
+ {
+ // Arrange
+ var longValue = new string('x', 50);
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ longValue,
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act & Assert
+ sut.DisplayValue.Length.ShouldBe(20);
+ sut.DisplayValue.ShouldBe(new string('\u2022', 20));
+ }
+
+ [Fact]
+ public void DisplayValue_HandlesEmptyValue()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act & Assert
+ sut.DisplayValue.ShouldBe("");
+ }
+
+ [Fact]
+ public void VisibilityButtonText_ShowsShow_WhenHidden()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act & Assert
+ sut.VisibilityButtonText.ShouldBe("Show");
+ }
+
+ [Fact]
+ public void VisibilityButtonText_ShowsHide_WhenVisible()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act
+ sut.IsValueVisible = true;
+
+ // Assert
+ sut.VisibilityButtonText.ShouldBe("Hide");
+ }
+
+ [Fact]
+ public void ToggleVisibilityCommand_TogglesVisibility()
+ {
+ // Arrange
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act & Assert - Toggle on
+ sut.ToggleVisibilityCommand.Execute(null);
+ sut.IsValueVisible.ShouldBeTrue();
+
+ // Act & Assert - Toggle off
+ sut.ToggleVisibilityCommand.Execute(null);
+ sut.IsValueVisible.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task CopyToClipboardCommand_CopiesValueToClipboard()
+ {
+ // Arrange
+ _clipboardService.SetTextAsync(Arg.Any()).Returns(Task.CompletedTask);
+
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret-to-copy",
+ _clipboardService,
+ _ => { },
+ () => { });
+
+ // Act
+ sut.CopyToClipboardCommand.Execute(null);
+
+ // Small delay to allow async command to complete
+ await Task.Delay(50);
+
+ // Assert
+ await _clipboardService.Received(1).SetTextAsync("secret-to-copy");
+ }
+
+ [Fact]
+ public void DeleteCommand_InvokesCallback()
+ {
+ // Arrange
+ var deleteCalled = false;
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "secret",
+ _clipboardService,
+ _ => { },
+ () => deleteCalled = true);
+
+ // Act
+ sut.DeleteCommand.Execute(null);
+
+ // Assert
+ deleteCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullKey()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecretFormViewModel(null!, "value", _clipboardService, _ => { }, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullClipboardService()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecretFormViewModel("key", "value", null!, _ => { }, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullValueChangedCallback()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecretFormViewModel("key", "value", _clipboardService, null!, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullDeleteCallback()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecretFormViewModel("key", "value", _clipboardService, _ => { }, null!));
+ }
+
+ [Fact]
+ public void Key_IsReadOnly()
+ {
+ // Assert - Verify Key property is get-only
+ var keyProperty = typeof(SecretFormViewModel).GetProperty(nameof(SecretFormViewModel.Key));
+ keyProperty!.CanWrite.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Value_DoesNotInvokeCallback_WhenValueUnchanged()
+ {
+ // Arrange
+ var callbackCount = 0;
+ var sut = new SecretFormViewModel(
+ "API_KEY",
+ "same-value",
+ _clipboardService,
+ _ => callbackCount++,
+ () => { });
+
+ // Act
+ sut.Value = "same-value"; // Same as initial value
+
+ // Assert
+ callbackCount.ShouldBe(0);
+ }
+}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs
new file mode 100644
index 0000000..180dc59
--- /dev/null
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs
@@ -0,0 +1,115 @@
+using JdeScoping.ConfigManager.ViewModels.Forms;
+
+namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
+
+public class SecureStoreLockedFormViewModelTests
+{
+ [Fact]
+ public void Constructor_SetsPropertiesCorrectly()
+ {
+ // Arrange
+ var lastModified = DateTime.Now;
+
+ // Act
+ var sut = new SecureStoreLockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ lastModified,
+ () => { });
+
+ // Assert
+ sut.StoreName.ShouldBe("test.secrets");
+ sut.StorePath.ShouldBe("/path/to/test.secrets");
+ sut.LastModified.ShouldBe(lastModified);
+ }
+
+ [Fact]
+ public void Constructor_WithNullLastModified_SetsNullLastModified()
+ {
+ // Arrange & Act
+ var sut = new SecureStoreLockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ null,
+ () => { });
+
+ // Assert
+ sut.LastModified.ShouldBeNull();
+ }
+
+ [Fact]
+ public void UnlockCommand_InvokesCallback()
+ {
+ // Arrange
+ var unlockCalled = false;
+ var sut = new SecureStoreLockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ null,
+ () => unlockCalled = true);
+
+ // Act
+ sut.UnlockCommand.Execute(null);
+
+ // Assert
+ unlockCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void UnlockCommand_CanExecute_ReturnsTrue()
+ {
+ // Arrange
+ var sut = new SecureStoreLockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ null,
+ () => { });
+
+ // Act & Assert
+ sut.UnlockCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullStoreName()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecureStoreLockedFormViewModel(null!, "/path", null, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullStorePath()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecureStoreLockedFormViewModel("test", null!, null, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullUnlockCallback()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecureStoreLockedFormViewModel("test", "/path", null, null!));
+ }
+
+ [Fact]
+ public void Properties_AreReadOnly()
+ {
+ // Arrange
+ var sut = new SecureStoreLockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ DateTime.Now,
+ () => { });
+
+ // Assert - Verify properties are get-only (no setters)
+ var storeNameProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.StoreName));
+ var storePathProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.StorePath));
+ var lastModifiedProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.LastModified));
+
+ storeNameProperty!.CanWrite.ShouldBeFalse();
+ storePathProperty!.CanWrite.ShouldBeFalse();
+ lastModifiedProperty!.CanWrite.ShouldBeFalse();
+ }
+}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs
new file mode 100644
index 0000000..3c0d284
--- /dev/null
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs
@@ -0,0 +1,327 @@
+using JdeScoping.ConfigManager.ViewModels.Forms;
+
+namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
+
+public class SecureStoreUnlockedFormViewModelTests
+{
+ [Fact]
+ public void Constructor_SetsPropertiesCorrectly()
+ {
+ // Arrange & Act
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 5,
+ false,
+ () => { },
+ () => { },
+ () => { });
+
+ // Assert
+ sut.StoreName.ShouldBe("test.secrets");
+ sut.StorePath.ShouldBe("/path/to/test.secrets");
+ sut.SecretCount.ShouldBe(5);
+ sut.HasUnsavedChanges.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Constructor_WithUnsavedChanges_SetsHasUnsavedChanges()
+ {
+ // Arrange & Act
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 3,
+ true,
+ () => { },
+ () => { },
+ () => { });
+
+ // Assert
+ sut.HasUnsavedChanges.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void HasUnsavedChanges_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => { },
+ () => { },
+ () => { });
+
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecureStoreUnlockedFormViewModel.HasUnsavedChanges))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.HasUnsavedChanges = true;
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void HasUnsavedChanges_DoesNotRaisePropertyChanged_WhenValueUnchanged()
+ {
+ // Arrange
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => { },
+ () => { },
+ () => { });
+
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(SecureStoreUnlockedFormViewModel.HasUnsavedChanges))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.HasUnsavedChanges = false; // Same as initial value
+
+ // Assert
+ propertyChangedRaised.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void LockCommand_InvokesCallback()
+ {
+ // Arrange
+ var lockCalled = false;
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => lockCalled = true,
+ () => { },
+ () => { });
+
+ // Act
+ sut.LockCommand.Execute(null);
+
+ // Assert
+ lockCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void AddSecretCommand_InvokesCallback()
+ {
+ // Arrange
+ var addSecretCalled = false;
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => { },
+ () => addSecretCalled = true,
+ () => { });
+
+ // Act
+ sut.AddSecretCommand.Execute(null);
+
+ // Assert
+ addSecretCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void SaveCommand_InvokesCallback()
+ {
+ // Arrange
+ var saveCalled = false;
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ true, // Must have unsaved changes for save to be enabled
+ () => { },
+ () => { },
+ () => saveCalled = true);
+
+ // Act
+ sut.SaveCommand.Execute(null);
+
+ // Assert
+ saveCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void SaveCommand_CanExecute_ReturnsFalse_WhenNoUnsavedChanges()
+ {
+ // Arrange
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => { },
+ () => { },
+ () => { });
+
+ // Act & Assert
+ sut.SaveCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void SaveCommand_CanExecute_ReturnsTrue_WhenHasUnsavedChanges()
+ {
+ // Arrange
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ true,
+ () => { },
+ () => { },
+ () => { });
+
+ // Act & Assert
+ sut.SaveCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void SaveCommand_CanExecute_Updates_WhenHasUnsavedChangesChanges()
+ {
+ // Arrange
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => { },
+ () => { },
+ () => { });
+
+ // Initial state - can't save
+ sut.SaveCommand.CanExecute(null).ShouldBeFalse();
+
+ // Act
+ sut.HasUnsavedChanges = true;
+
+ // Assert
+ sut.SaveCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void SaveCommand_RaisesCanExecuteChanged_WhenHasUnsavedChangesChanges()
+ {
+ // Arrange
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => { },
+ () => { },
+ () => { });
+
+ var canExecuteChangedRaised = false;
+ sut.SaveCommand.CanExecuteChanged += (s, e) => canExecuteChangedRaised = true;
+
+ // Act
+ sut.HasUnsavedChanges = true;
+
+ // Assert
+ canExecuteChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void LockCommand_CanExecute_ReturnsTrue()
+ {
+ // Arrange
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => { },
+ () => { },
+ () => { });
+
+ // Act & Assert
+ sut.LockCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void AddSecretCommand_CanExecute_ReturnsTrue()
+ {
+ // Arrange
+ var sut = new SecureStoreUnlockedFormViewModel(
+ "test.secrets",
+ "/path/to/test.secrets",
+ 0,
+ false,
+ () => { },
+ () => { },
+ () => { });
+
+ // Act & Assert
+ sut.AddSecretCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullStoreName()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecureStoreUnlockedFormViewModel(null!, "/path", 0, false, () => { }, () => { }, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullStorePath()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecureStoreUnlockedFormViewModel("test", null!, 0, false, () => { }, () => { }, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullLockCallback()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, null!, () => { }, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullAddSecretCallback()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, null!, () => { }));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullSaveCallback()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, () => { }, null!));
+ }
+
+ [Fact]
+ public void ReadOnlyProperties_CannotBeModified()
+ {
+ // Assert - Verify StoreName, StorePath, and SecretCount are get-only
+ var storeNameProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.StoreName));
+ var storePathProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.StorePath));
+ var secretCountProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.SecretCount));
+
+ storeNameProperty!.CanWrite.ShouldBeFalse();
+ storePathProperty!.CanWrite.ShouldBeFalse();
+ secretCountProperty!.CanWrite.ShouldBeFalse();
+ }
+}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs
index 7afc6c0..3532671 100644
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs
@@ -1,5 +1,6 @@
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
+using JdeScoping.ConfigManager.Services.SecureStore;
using JdeScoping.ConfigManager.ViewModels;
using JdeScoping.ConfigManager.ViewModels.Forms;
using Microsoft.Extensions.Logging;
@@ -15,6 +16,8 @@ public class MainWindowViewModelTests
private readonly IBackupService _backupService;
private readonly IAutoDiscoveryService _autoDiscoveryService;
private readonly IDialogService _dialogService;
+ private readonly ISecureStoreManager _secureStoreManager;
+ private readonly IClipboardService _clipboardService;
private readonly ILogger _logger;
public MainWindowViewModelTests()
@@ -25,6 +28,8 @@ public class MainWindowViewModelTests
_backupService = Substitute.For();
_autoDiscoveryService = Substitute.For();
_dialogService = Substitute.For();
+ _secureStoreManager = Substitute.For();
+ _clipboardService = Substitute.For();
_logger = Substitute.For>();
_validationService.ValidateAppSettings(Arg.Any())
@@ -274,7 +279,7 @@ public class MainWindowViewModelTests
sut.LoadConfigForTesting(config, null);
// Assert
- sut.TreeNodes.Count.ShouldBe(2); // Settings and Pipelines folders
+ sut.TreeNodes.Count.ShouldBe(3); // Settings, Pipelines, and Secure Stores folders
sut.TreeNodes[0].Name.ShouldBe("Settings");
sut.TreeNodes[0].Children.Count.ShouldBe(6); // DataSync, DataAccess, Auth, Ldap, Search, ExcelExport
}
@@ -311,6 +316,8 @@ public class MainWindowViewModelTests
_backupService,
_autoDiscoveryService,
_dialogService,
+ _secureStoreManager,
+ _clipboardService,
_logger);
}
}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs
index 2a071df..947e3ae 100644
--- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs
@@ -218,4 +218,180 @@ public class TreeNodeViewModelTests
// Assert
propertyChangedRaised.ShouldBeFalse();
}
+
+ #region SecureStore Node Type Tests
+
+ [Theory]
+ [InlineData(TreeNodeType.SecureStoresFolder)]
+ [InlineData(TreeNodeType.SecureStore)]
+ [InlineData(TreeNodeType.Secret)]
+ public void Constructor_WithSecureStoreNodeTypes_SetsNodeTypeCorrectly(TreeNodeType nodeType)
+ {
+ // Arrange & Act
+ var sut = new TreeNodeViewModel("Test", "icon", nodeType);
+
+ // Assert
+ sut.NodeType.ShouldBe(nodeType);
+ }
+
+ [Fact]
+ public void IsUnlocked_DefaultsToFalse()
+ {
+ // Arrange & Act
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+
+ // Assert
+ sut.IsUnlocked.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsUnlocked_WhenSet_RaisesPropertyChanged()
+ {
+ // Arrange
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(TreeNodeViewModel.IsUnlocked))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.IsUnlocked = true;
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsUnlocked_WhenSet_RaisesPropertyChangedForLockIcon()
+ {
+ // Arrange
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+ var lockIconChanged = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(TreeNodeViewModel.LockIcon))
+ lockIconChanged = true;
+ };
+
+ // Act
+ sut.IsUnlocked = true;
+
+ // Assert
+ lockIconChanged.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void LockIcon_WhenLocked_ReturnsLockedIcon()
+ {
+ // Arrange & Act
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+
+ // Assert
+ sut.LockIcon.ShouldBe("🔒");
+ }
+
+ [Fact]
+ public void LockIcon_WhenUnlocked_ReturnsUnlockedIcon()
+ {
+ // Arrange
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+
+ // Act
+ sut.IsUnlocked = true;
+
+ // Assert
+ sut.LockIcon.ShouldBe("🔓");
+ }
+
+ [Fact]
+ public void IsLocked_WhenUnlocked_ReturnsFalse()
+ {
+ // Arrange
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+
+ // Act
+ sut.IsUnlocked = true;
+
+ // Assert
+ sut.IsLocked.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsLocked_WhenLocked_ReturnsTrue()
+ {
+ // Arrange & Act
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+
+ // Assert
+ sut.IsLocked.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsUnlocked_WhenSet_RaisesPropertyChangedForIsLocked()
+ {
+ // Arrange
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+ var isLockedChanged = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(TreeNodeViewModel.IsLocked))
+ isLockedChanged = true;
+ };
+
+ // Act
+ sut.IsUnlocked = true;
+
+ // Assert
+ isLockedChanged.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void StorePath_CanBeSetViaInitializer()
+ {
+ // Arrange & Act
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore)
+ {
+ StorePath = "/path/to/store.secrets"
+ };
+
+ // Assert
+ sut.StorePath.ShouldBe("/path/to/store.secrets");
+ }
+
+ [Fact]
+ public void StorePath_DefaultsToNull()
+ {
+ // Arrange & Act
+ var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
+
+ // Assert
+ sut.StorePath.ShouldBeNull();
+ }
+
+ [Fact]
+ public void SecretKey_CanBeSetViaInitializer()
+ {
+ // Arrange & Act
+ var sut = new TreeNodeViewModel("MySecret", "🔑", TreeNodeType.Secret)
+ {
+ SecretKey = "ConnectionStrings:Database"
+ };
+
+ // Assert
+ sut.SecretKey.ShouldBe("ConnectionStrings:Database");
+ }
+
+ [Fact]
+ public void SecretKey_DefaultsToNull()
+ {
+ // Arrange & Act
+ var sut = new TreeNodeViewModel("MySecret", "🔑", TreeNodeType.Secret);
+
+ // Assert
+ sut.SecretKey.ShouldBeNull();
+ }
+
+ #endregion
}
diff --git a/PLANS/2026-01-20-securestore-integration.md b/PLANS/2026-01-20-securestore-integration.md
new file mode 100644
index 0000000..8ff80b9
--- /dev/null
+++ b/PLANS/2026-01-20-securestore-integration.md
@@ -0,0 +1,513 @@
+# SecureStoreManager Integration into ConfigManager
+
+**Date:** 2026-01-20
+**Status:** Ready for Implementation
+**Estimated Phases:** 9
+
+## Overview
+
+Merge the complete functionality of SecureStoreManager into ConfigManager, creating a unified configuration and secrets management application. This integration adds encrypted secret store management to the existing configuration editing capabilities.
+
+## Design Decisions
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| Integration model | Tree node with explicit auth | Security: secrets require conscious authentication |
+| Store discovery | Hybrid (auto-discover + manual add) | Convenience for co-located stores, flexibility for external |
+| Credential caching | Prompt every time (session cache opt-in) | Default to most secure behavior |
+| Save behavior | Separate saves (config vs stores) | Different security domains shouldn't couple |
+| Service architecture | Use cases layer | Matches existing pattern, separation of concerns |
+
+## Architecture
+
+### Tree Structure
+```
+📁 Configuration
+ ├── DataSync
+ ├── DataAccess
+ ├── Auth
+ ├── LDAP
+ ├── Search
+ └── Excel Export
+📁 Pipelines
+ └── (pipeline nodes)
+🛡️ Secure Stores
+ ├── 🔒 production.secrets (locked)
+ └── 🔓 development.secrets (unlocked)
+ ├── 🔑 ConnectionStrings:JDE
+ └── 🔑 LdapPassword
+```
+
+### Service Layer
+```
+MainWindowViewModel
+├── IConfigFileService (existing - config operations)
+├── IStoreUseCases (new - secure store operations)
+└── ISecretUseCases (new - secret operations)
+ └── ISecureStoreManager (new - low-level encryption)
+```
+
+---
+
+## Phase 1: Project Setup & Dependencies
+
+### 1.1 Add SecureStore NuGet Package
+**File:** `NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj`
+
+Add:
+```xml
+
+```
+
+### 1.2 Add Avalonia.Headless.XUnit to Test Project
+**File:** `NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj`
+
+Add:
+```xml
+
+```
+
+---
+
+## Phase 2: Copy Service Layer
+
+### 2.1 Copy Core Service Files
+Copy from `SecureStoreManager/Services/` to `ConfigManager/Services/SecureStore/`:
+
+| Source | Destination |
+|--------|-------------|
+| `ISecureStoreManager.cs` | `Services/SecureStore/ISecureStoreManager.cs` |
+| `SecureStoreManager.cs` | `Services/SecureStore/SecureStoreManager.cs` |
+
+Update namespace: `JdeScoping.SecureStoreManager.Services` → `JdeScoping.ConfigManager.Services.SecureStore`
+
+### 2.2 Copy Use Cases
+Copy from `SecureStoreManager/Application/` to `ConfigManager/Application/`:
+
+| Source | Destination |
+|--------|-------------|
+| `StoreUseCases.cs` | `Application/StoreUseCases.cs` |
+| `SecretUseCases.cs` | `Application/SecretUseCases.cs` |
+
+Update namespaces accordingly.
+
+### 2.3 Copy Constants
+Copy from `SecureStoreManager/Constants/` to `ConfigManager/Constants/`:
+
+| Source | Destination |
+|--------|-------------|
+| `DialogStrings.cs` | `Constants/SecureStoreStrings.cs` (rename to avoid conflicts) |
+| `FileExtensions.cs` | `Constants/SecureStoreFileExtensions.cs` |
+
+### 2.4 Copy Clipboard Service
+Copy from `SecureStoreManager/Services/` to `ConfigManager/Services/`:
+
+| Source | Destination |
+|--------|-------------|
+| `IClipboardService.cs` | `Services/IClipboardService.cs` |
+| `AvaloniaClipboardService.cs` | `Services/AvaloniaClipboardService.cs` |
+
+---
+
+## Phase 3: Extend Tree Node Model
+
+### 3.1 Update TreeNodeType Enum
+**File:** `ViewModels/TreeNodeViewModel.cs`
+
+```csharp
+public enum TreeNodeType
+{
+ Folder,
+ SettingsSection,
+ Pipeline,
+ SecureStoresFolder, // New
+ SecureStore, // New
+ Secret // New
+}
+```
+
+### 3.2 Add SecureStore-Specific Properties
+**File:** `ViewModels/TreeNodeViewModel.cs`
+
+Add to `TreeNodeViewModel`:
+```csharp
+///
+/// Gets or sets whether this secure store is currently unlocked.
+/// Only applicable for SecureStore node types.
+///
+public bool IsUnlocked
+{
+ get => _isUnlocked;
+ set
+ {
+ if (SetProperty(ref _isUnlocked, value))
+ {
+ OnPropertyChanged(nameof(LockIcon));
+ OnPropertyChanged(nameof(IsLocked));
+ }
+ }
+}
+
+///
+/// Gets whether this secure store is locked.
+///
+public bool IsLocked => !IsUnlocked;
+
+///
+/// Gets the lock icon for secure store nodes.
+///
+public string LockIcon => IsUnlocked ? "🔓" : "🔒";
+
+///
+/// Gets or sets the full path to the secure store file.
+/// Only applicable for SecureStore node types.
+///
+public string? StorePath { get; init; }
+```
+
+### 3.3 Update Icon Property Logic
+Update the `Icon` property or add computed property for dynamic icons based on node type and lock state.
+
+---
+
+## Phase 4: Create New ViewModels
+
+### 4.1 SecureStoreFormViewModel (Locked State)
+**File:** `ViewModels/Forms/SecureStoreLockedFormViewModel.cs`
+
+Displays when a locked store is selected:
+- Store name and path
+- "This store is locked" message
+- "Unlock Store..." button
+- Last modified date (from file system)
+
+### 4.2 SecureStoreFormViewModel (Unlocked State)
+**File:** `ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs`
+
+Displays when an unlocked store is selected:
+- Store name and path
+- Secret count
+- "Lock Store" button
+- "Add Secret" button
+- List of secrets (or indicate to select a secret from tree)
+
+### 4.3 SecretFormViewModel
+**File:** `ViewModels/Forms/SecretFormViewModel.cs`
+
+Displays when a secret is selected:
+- Key (read-only)
+- Value (password-masked by default)
+- Show/Hide toggle button
+- Copy to Clipboard button
+- Delete button
+
+Properties:
+```csharp
+public string Key { get; }
+public string Value { get; set; }
+public bool IsValueVisible { get; set; }
+public string DisplayValue => IsValueVisible ? Value : new string('*', 8);
+public ICommand ToggleVisibilityCommand { get; }
+public ICommand CopyToClipboardCommand { get; }
+```
+
+### 4.4 Dialog ViewModels
+Copy and adapt from SecureStoreManager:
+
+| Source | Destination |
+|--------|-------------|
+| `NewStoreDialogViewModel.cs` | `ViewModels/Dialogs/NewStoreDialogViewModel.cs` |
+| `OpenStoreDialogViewModel.cs` | `ViewModels/Dialogs/UnlockStoreDialogViewModel.cs` (rename) |
+| `SecretEditDialogViewModel.cs` | `ViewModels/Dialogs/SecretEditDialogViewModel.cs` |
+
+---
+
+## Phase 5: Create New Views
+
+### 5.1 Form Views
+**Directory:** `Views/Forms/`
+
+| File | Purpose |
+|------|---------|
+| `SecureStoreLockedFormView.axaml` | Shows unlock button for locked stores |
+| `SecureStoreUnlockedFormView.axaml` | Shows store info when unlocked |
+| `SecretFormView.axaml` | Secret key/value editor with masking |
+
+### 5.2 Dialog Views
+**Directory:** `Views/Dialogs/`
+
+| File | Purpose |
+|------|---------|
+| `NewStoreDialog.axaml` | Create new secure store |
+| `UnlockStoreDialog.axaml` | Enter credentials to unlock |
+| `SecretEditDialog.axaml` | Add/edit secret key-value |
+
+### 5.3 Update DataTemplates
+**File:** `App.axaml`
+
+Add DataTemplates for automatic form selection:
+```xml
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Phase 6: Update MainWindowViewModel
+
+### 6.1 Add Dependencies
+```csharp
+private readonly IStoreUseCases _storeUseCases;
+private readonly ISecretUseCases _secretUseCases;
+private readonly IClipboardService _clipboardService;
+```
+
+### 6.2 Add Secure Store Commands
+```csharp
+// Store commands
+public ICommand NewStoreCommand { get; }
+public ICommand AddExistingStoreCommand { get; }
+public ICommand UnlockStoreCommand { get; }
+public ICommand LockStoreCommand { get; }
+public ICommand SaveStoreCommand { get; }
+public ICommand LockAllStoresCommand { get; }
+public ICommand GenerateKeyFileCommand { get; }
+
+// Secret commands
+public ICommand AddSecretCommand { get; }
+public ICommand EditSecretCommand { get; }
+public ICommand DeleteSecretCommand { get; }
+public ICommand CopySecretCommand { get; }
+```
+
+### 6.3 Add Store State Tracking
+```csharp
+// Track open stores (store path -> credentials for session caching if enabled)
+private readonly Dictionary _openStores = new();
+
+private class SecureStoreState
+{
+ public bool IsUnlocked { get; set; }
+ public TreeNodeViewModel TreeNode { get; set; }
+}
+```
+
+### 6.4 Update BuildTreeNodes Method
+Add logic to:
+1. Create "Secure Stores" folder node
+2. Discover `*.secrets.json` files in config folder
+3. Create SecureStore nodes for each discovered file
+4. Add manual store tracking
+
+### 6.5 Update Form Selection Logic
+Extend `OnSelectedNodeChanged` to handle:
+- `SecureStoresFolder` → Show instructions/empty state
+- `SecureStore` (locked) → Show `SecureStoreLockedFormViewModel`
+- `SecureStore` (unlocked) → Show `SecureStoreUnlockedFormViewModel`
+- `Secret` → Show `SecretFormViewModel`
+
+---
+
+## Phase 7: Update Menu Bar and Toolbar
+
+### 7.1 Add Secure Stores Menu
+**File:** `Views/MainWindow.axaml`
+
+Add new menu between "Tools" and "Help":
+```xml
+
+
+
+
+
+
+
+
+
+```
+
+### 7.2 Add Toolbar Buttons
+Add after existing buttons with separator:
+```xml
+
+
+
+```
+
+### 7.3 Add Context Menus
+Add context menu to TreeView for right-click actions on nodes.
+
+---
+
+## Phase 8: Register Services
+
+### 8.1 Update App.axaml.cs
+**File:** `App.axaml.cs`
+
+Add to `ConfigureServices`:
+```csharp
+// SecureStore Services
+services.AddSingleton();
+services.AddSingleton(sp =>
+ new AvaloniaClipboardService(GetMainWindow));
+
+// SecureStore Use Cases
+services.AddSingleton();
+services.AddSingleton();
+```
+
+---
+
+## Phase 9: Migrate Tests
+
+### 9.1 Copy Service Tests
+Copy from `SecureStoreManager.Tests/Services/` to `ConfigManager.Tests/Services/SecureStore/`:
+
+| Source | Destination |
+|--------|-------------|
+| `SecureStoreManagerTests.cs` | `Services/SecureStore/SecureStoreManagerTests.cs` |
+
+Update namespaces.
+
+### 9.2 Create New ViewModel Tests
+**Directory:** `ConfigManager.Tests/ViewModels/Forms/`
+
+| File | Tests |
+|------|-------|
+| `SecureStoreLockedFormViewModelTests.cs` | Locked state display, unlock command |
+| `SecureStoreUnlockedFormViewModelTests.cs` | Unlocked state, secret list, lock command |
+| `SecretFormViewModelTests.cs` | Value masking, visibility toggle, copy |
+
+### 9.3 Create Dialog ViewModel Tests
+**Directory:** `ConfigManager.Tests/ViewModels/Dialogs/`
+
+| File | Tests |
+|------|-------|
+| `NewStoreDialogViewModelTests.cs` | Validation, path selection |
+| `UnlockStoreDialogViewModelTests.cs` | Credential validation |
+| `SecretEditDialogViewModelTests.cs` | Key/value validation |
+
+### 9.4 Extend MainWindowViewModelTests
+Add tests for:
+- Secure store tree node creation
+- Store unlock/lock workflow
+- Secret CRUD operations
+- Form selection for store/secret nodes
+
+### 9.5 Extend TreeNodeViewModelTests
+Add tests for:
+- New node types (SecureStoresFolder, SecureStore, Secret)
+- Lock state properties
+- Icon computation for store nodes
+
+### 9.6 Create UI Tests (Optional)
+If UI testing is desired, copy patterns from `SecureStoreManager.Tests/Views/`:
+- Test dialog visual structure
+- Test form view elements
+
+---
+
+## File Inventory
+
+### New Files to Create
+
+**Services (5 files):**
+- `Services/SecureStore/ISecureStoreManager.cs`
+- `Services/SecureStore/SecureStoreManager.cs`
+- `Services/IClipboardService.cs`
+- `Services/AvaloniaClipboardService.cs`
+
+**Application (2 files):**
+- `Application/StoreUseCases.cs`
+- `Application/SecretUseCases.cs`
+
+**Constants (2 files):**
+- `Constants/SecureStoreStrings.cs`
+- `Constants/SecureStoreFileExtensions.cs`
+
+**ViewModels (6 files):**
+- `ViewModels/Forms/SecureStoreLockedFormViewModel.cs`
+- `ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs`
+- `ViewModels/Forms/SecretFormViewModel.cs`
+- `ViewModels/Dialogs/NewStoreDialogViewModel.cs`
+- `ViewModels/Dialogs/UnlockStoreDialogViewModel.cs`
+- `ViewModels/Dialogs/SecretEditDialogViewModel.cs`
+
+**Views (6 files):**
+- `Views/Forms/SecureStoreLockedFormView.axaml` + `.cs`
+- `Views/Forms/SecureStoreUnlockedFormView.axaml` + `.cs`
+- `Views/Forms/SecretFormView.axaml` + `.cs`
+- `Views/Dialogs/NewStoreDialog.axaml` + `.cs`
+- `Views/Dialogs/UnlockStoreDialog.axaml` + `.cs`
+- `Views/Dialogs/SecretEditDialog.axaml` + `.cs`
+
+**Tests (8+ files):**
+- `Services/SecureStore/SecureStoreManagerTests.cs`
+- `ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs`
+- `ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs`
+- `ViewModels/Forms/SecretFormViewModelTests.cs`
+- `ViewModels/Dialogs/NewStoreDialogViewModelTests.cs`
+- `ViewModels/Dialogs/UnlockStoreDialogViewModelTests.cs`
+- `ViewModels/Dialogs/SecretEditDialogViewModelTests.cs`
+
+### Files to Modify
+
+- `JdeScoping.ConfigManager.csproj` (add SecureStore package)
+- `App.axaml.cs` (register services)
+- `App.axaml` (add DataTemplates)
+- `ViewModels/TreeNodeViewModel.cs` (add enum values, properties)
+- `ViewModels/MainWindowViewModel.cs` (add commands, store handling)
+- `Views/MainWindow.axaml` (add menu, toolbar, context menu)
+- `JdeScoping.ConfigManager.Tests.csproj` (add Avalonia.Headless.XUnit)
+
+---
+
+## Implementation Order
+
+1. **Phase 1** - Project setup (dependencies)
+2. **Phase 2** - Copy service layer (foundation)
+3. **Phase 3** - Extend tree model (data structure)
+4. **Phase 4** - Create ViewModels (business logic)
+5. **Phase 5** - Create Views (UI)
+6. **Phase 6** - Update MainWindowViewModel (orchestration)
+7. **Phase 7** - Update menus/toolbar (discoverability)
+8. **Phase 8** - Register services (wiring)
+9. **Phase 9** - Migrate tests (verification)
+
+---
+
+## Verification Checklist
+
+After implementation, verify:
+
+- [ ] Can create new secure store with key file
+- [ ] Can create new secure store with password
+- [ ] Can add existing store to tree
+- [ ] Stores appear locked by default
+- [ ] Double-click unlocks store (shows dialog)
+- [ ] Unlock button in form works
+- [ ] Unlocked store shows secrets in tree
+- [ ] Can add new secret
+- [ ] Can edit existing secret
+- [ ] Can delete secret
+- [ ] Secret value is masked by default
+- [ ] Show/Hide toggle works
+- [ ] Copy to clipboard works
+- [ ] Can lock store manually
+- [ ] Lock All Stores works
+- [ ] Save Store saves only that store
+- [ ] Save (Ctrl+S) only saves config
+- [ ] Unsaved changes indicator works for stores
+- [ ] Generate Key File works
+- [ ] All tests pass