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/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 @@ + +``` + +### 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