refactor(configmanager): simplify SecureStore UI with unified info view

Consolidate SecureStoreLockedFormView and SecureStoreUnlockedFormView into
a single SecureStoreInfoFormView that displays store status and metadata.
Simplifies MainWindowViewModel by removing redundant state management.
Also adds design docs for RegexTransformer feature.
This commit is contained in:
Joseph Doherty
2026-01-22 09:40:38 -05:00
parent 5669bac221
commit 9bf0c29add
28 changed files with 2811 additions and 1527 deletions
+10 -1
View File
@@ -145,9 +145,18 @@
"RequiredKeys": [
"RsaPrivateKey",
"ExcelExport:CriteriaSheetPassword",
"ExcelExport:DataSheetPassword"
"ExcelExport:DataSheetPassword",
"JdeUser",
"JdePassword",
"GiwUser",
"GiwPassword",
"CmsUser",
"CmsPassword"
]
},
"Pipelines": {
"ConfigPath": "Pipelines/pipelines.json"
},
"WorkProcessor": {
"Enabled": true,
"WorkInterval": "00:00:05",
@@ -23,55 +23,33 @@ public class StoreUseCases
}
/// <summary>
/// Creates a new store with either key file or password authentication.
/// Creates a new store with key file authentication.
/// </summary>
/// <param name="storePath">The path where the store will be created.</param>
/// <param name="keyFilePath">The path to the key file, or null for password-based authentication.</param>
/// <param name="password">The password for authentication, or null for key file-based authentication.</param>
public void CreateStore(string storePath, string? keyFilePath, string? password)
/// <param name="keyFilePath">The path to the key file.</param>
public void CreateStore(string storePath, string keyFilePath)
{
_logger.LogInformation("Creating store at {StorePath}", storePath);
if (string.IsNullOrEmpty(keyFilePath))
throw new ArgumentException("Key file path must be provided.", nameof(keyFilePath));
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.");
}
_logger.LogInformation("Creating store at {StorePath}", storePath);
_storeManager.CreateStore(storePath, keyFilePath);
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
}
/// <summary>
/// Opens an existing store with either key file or password authentication.
/// Opens an existing store with key file authentication.
/// </summary>
/// <param name="storePath">The path to the existing store.</param>
/// <param name="keyFilePath">The path to the key file, or null for password-based authentication.</param>
/// <param name="password">The password for authentication, or null for key file-based authentication.</param>
public void OpenStore(string storePath, string? keyFilePath, string? password)
/// <param name="keyFilePath">The path to the key file.</param>
public void OpenStore(string storePath, string keyFilePath)
{
_logger.LogInformation("Opening store at {StorePath}", storePath);
if (string.IsNullOrEmpty(keyFilePath))
throw new ArgumentException("Key file path must be provided.", nameof(keyFilePath));
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.");
}
_logger.LogInformation("Opening store at {StorePath}", storePath);
_storeManager.OpenStore(storePath, keyFilePath);
_logger.LogDebug("Store opened with key file");
}
/// <summary>
@@ -41,6 +41,16 @@ public class ConfigModel
/// Gets or sets the connection strings for external data sources.
/// </summary>
public Dictionary<string, string> ConnectionStrings { get; set; } = new();
/// <summary>
/// Gets or sets the secure store configuration.
/// </summary>
public SecureStoreSection SecureStore { get; set; } = new();
/// <summary>
/// Gets or sets the pipelines configuration.
/// </summary>
public PipelinesSection Pipelines { get; set; } = new();
}
public class DataSyncSection
@@ -230,3 +240,45 @@ public class ExcelExportSection
/// </summary>
public string TimezoneAbbreviation { get; set; } = "CT";
}
/// <summary>
/// Configuration section for the secure store.
/// </summary>
public class SecureStoreSection
{
/// <summary>
/// Gets or sets the path to the secure store file.
/// </summary>
public string StorePath { get; set; } = "data/secrets.json";
/// <summary>
/// Gets or sets the path to the key file for decryption.
/// </summary>
public string KeyFilePath { get; set; } = "data/secrets.key";
/// <summary>
/// Gets or sets the environment variable name for the master key.
/// </summary>
public string MasterKeyEnvVar { get; set; } = "SCOPINGTOOL_MASTER_KEY";
/// <summary>
/// Gets or sets a value indicating whether to auto-create the store if it doesn't exist.
/// </summary>
public bool AutoCreateStore { get; set; } = true;
/// <summary>
/// Gets or sets the list of required secret keys that must exist in the store.
/// </summary>
public List<string> RequiredKeys { get; set; } = [];
}
/// <summary>
/// Configuration section for pipelines.
/// </summary>
public class PipelinesSection
{
/// <summary>
/// Gets or sets the path to the pipelines configuration file.
/// </summary>
public string ConfigPath { get; set; } = "Pipelines/pipelines.json";
}
@@ -39,6 +39,29 @@ public class AvaloniaDialogService : IDialogService
return folders.Count > 0 ? folders[0].Path.LocalPath : null;
}
/// <inheritdoc />
public async Task<string?> ShowFilePickerAsync(string? title = null)
{
var window = _getMainWindow();
if (window == null)
return null;
var files = await window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = title ?? "Select File",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("JSON Files")
{
Patterns = new[] { "*.json" }
}
}
});
return files.Count > 0 ? files[0].Path.LocalPath : null;
}
/// <inheritdoc />
public async Task ShowMessageAsync(string title, string message)
{
@@ -13,6 +13,13 @@ public interface IDialogService
/// <returns>The selected folder path, or null if cancelled.</returns>
Task<string?> ShowFolderPickerAsync(string? title = null);
/// <summary>
/// Shows a file picker dialog filtered for JSON files.
/// </summary>
/// <param name="title">Optional title for the dialog.</param>
/// <returns>The selected file path, or null if cancelled.</returns>
Task<string?> ShowFilePickerAsync(string? title = null);
/// <summary>
/// Shows a message dialog.
/// </summary>
@@ -27,13 +27,6 @@ public interface ISecureStoreManager
/// <param name="keyFilePath">Path for the key file (.key).</param>
void CreateStore(string storePath, string keyFilePath);
/// <summary>
/// Creates a new store secured with a password.
/// </summary>
/// <param name="storePath">Path for the new store file (.json).</param>
/// <param name="password">Password to encrypt the store.</param>
void CreateStoreWithPassword(string storePath, string password);
/// <summary>
/// Opens an existing store using a key file.
/// </summary>
@@ -41,13 +34,6 @@ public interface ISecureStoreManager
/// <param name="keyFilePath">Path to the key file (.key).</param>
void OpenStore(string storePath, string keyFilePath);
/// <summary>
/// Opens an existing store using a password.
/// </summary>
/// <param name="storePath">Path to the store file (.json).</param>
/// <param name="password">Password to decrypt the store.</param>
void OpenStoreWithPassword(string storePath, string password);
/// <summary>
/// Closes the currently open store without saving.
/// </summary>
@@ -84,6 +70,13 @@ public interface ISecureStoreManager
/// <param name="key">The secret key to remove.</param>
void RemoveSecret(string key);
/// <summary>
/// Ensures all required keys exist in the store, creating blank values for any missing keys.
/// </summary>
/// <param name="requiredKeys">List of keys that must exist.</param>
/// <returns>List of keys that were added.</returns>
IReadOnlyList<string> EnsureRequiredKeys(IEnumerable<string> requiredKeys);
/// <summary>
/// Generates a new key file for use with store encryption.
/// </summary>
@@ -72,29 +72,6 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
}
/// <inheritdoc />
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");
}
/// <inheritdoc />
public void OpenStore(string storePath, string keyFilePath)
{
@@ -117,28 +94,6 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_logger.LogDebug("Store opened with key file, contains {KeyCount} keys", _keys.Count);
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public void CloseStore()
{
@@ -237,6 +192,33 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
_hasUnsavedChanges = true;
}
/// <inheritdoc />
public IReadOnlyList<string> EnsureRequiredKeys(IEnumerable<string> requiredKeys)
{
ThrowIfDisposed();
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
var addedKeys = new List<string>();
foreach (var key in requiredKeys)
{
if (!_keys.Contains(key))
{
_logger.LogInformation("Adding missing required key: {Key}", key);
SetSecret(key, string.Empty);
addedKeys.Add(key);
}
}
if (addedKeys.Count > 0)
{
Save();
}
return addedKeys.AsReadOnly();
}
/// <inheritdoc />
public void GenerateKeyFile(string path)
{
@@ -9,45 +9,25 @@ public class ExcelExportFormViewModel : ViewModelBase
{
private readonly ExcelExportSection _model;
private readonly Action _onChanged;
private string _selectedTimezone;
/// <summary>
/// Gets the list of available system timezones (IANA format for cross-platform compatibility).
/// </summary>
public static IReadOnlyList<string> AvailableTimezones { get; } = TimeZoneInfo
.GetSystemTimeZones()
.Select(tz => tz.Id)
.OrderBy(id => id)
.ToList()
.AsReadOnly();
public ExcelExportFormViewModel(ExcelExportSection model, Action onChanged)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
_onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
}
/// <summary>
/// Gets or sets the password for protecting the criteria worksheet.
/// </summary>
public string CriteriaSheetPassword
{
get => _model.CriteriaSheetPassword;
set
{
if (_model.CriteriaSheetPassword != value)
{
_model.CriteriaSheetPassword = value;
OnPropertyChanged();
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the password for protecting the data worksheet.
/// </summary>
public string DataSheetPassword
{
get => _model.DataSheetPassword;
set
{
if (_model.DataSheetPassword != value)
{
_model.DataSheetPassword = value;
OnPropertyChanged();
_onChanged();
}
}
// Initialize selected timezone from model (model defaults to "America/Chicago")
_selectedTimezone = _model.TimezoneId;
}
/// <summary>
@@ -119,36 +99,20 @@ public class ExcelExportFormViewModel : ViewModelBase
}
/// <summary>
/// Gets or sets the time zone identifier for date/time conversions.
/// Gets or sets the selected timezone from the dropdown.
/// </summary>
public string TimezoneId
public string SelectedTimezone
{
get => _model.TimezoneId;
get => _selectedTimezone;
set
{
if (_model.TimezoneId != value)
if (_selectedTimezone != value)
{
_selectedTimezone = value;
_model.TimezoneId = value;
OnPropertyChanged();
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the time zone abbreviation for display purposes.
/// </summary>
public string TimezoneAbbreviation
{
get => _model.TimezoneAbbreviation;
set
{
if (_model.TimezoneAbbreviation != value)
{
_model.TimezoneAbbreviation = value;
OnPropertyChanged();
_onChanged();
}
}
}
}
@@ -0,0 +1,40 @@
namespace JdeScoping.ConfigManager.ViewModels.Forms;
/// <summary>
/// View model for the SecureStore info panel shown when the store node is selected.
/// </summary>
public class SecureStoreInfoFormViewModel : ViewModelBase
{
/// <summary>
/// Gets the instruction text to display.
/// </summary>
public string InstructionText => "Select a secret from the tree to view or edit its value.";
/// <summary>
/// Gets the store path for display.
/// </summary>
public string StorePath { get; }
/// <summary>
/// Gets the key file path for display.
/// </summary>
public string KeyFilePath { get; }
/// <summary>
/// Gets the number of secrets in the store.
/// </summary>
public int SecretCount { get; }
/// <summary>
/// Initializes a new instance of the <see cref="SecureStoreInfoFormViewModel"/> class.
/// </summary>
/// <param name="storePath">The path to the secure store file.</param>
/// <param name="keyFilePath">The path to the key file.</param>
/// <param name="secretCount">The number of secrets in the store.</param>
public SecureStoreInfoFormViewModel(string storePath, string keyFilePath, int secretCount)
{
StorePath = storePath;
KeyFilePath = keyFilePath;
SecretCount = secretCount;
}
}
@@ -1,52 +0,0 @@
using System.Windows.Input;
namespace JdeScoping.ConfigManager.ViewModels.Forms;
/// <summary>
/// ViewModel for displaying a locked secure store with unlock capability.
/// </summary>
public class SecureStoreLockedFormViewModel : ViewModelBase
{
private readonly Action _onUnlockRequested;
/// <summary>
/// Initializes a new instance of the <see cref="SecureStoreLockedFormViewModel"/> class.
/// </summary>
/// <param name="storeName">The name of the secure store.</param>
/// <param name="storePath">The full path to the secure store file.</param>
/// <param name="lastModified">The last modified date of the store file.</param>
/// <param name="onUnlockRequested">The action to invoke when unlock is requested.</param>
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);
}
/// <summary>
/// Gets the name of the secure store.
/// </summary>
public string StoreName { get; }
/// <summary>
/// Gets the full path to the secure store file.
/// </summary>
public string StorePath { get; }
/// <summary>
/// Gets the last modified date of the store file.
/// </summary>
public DateTime? LastModified { get; }
/// <summary>
/// Gets the command to unlock the store.
/// </summary>
public ICommand UnlockCommand { get; }
}
@@ -1,92 +0,0 @@
using System.Windows.Input;
namespace JdeScoping.ConfigManager.ViewModels.Forms;
/// <summary>
/// ViewModel for displaying an unlocked secure store.
/// </summary>
public class SecureStoreUnlockedFormViewModel : ViewModelBase
{
private readonly Action _onLockRequested;
private readonly Action _onAddSecretRequested;
private readonly Action _onSaveRequested;
private bool _hasUnsavedChanges;
/// <summary>
/// Initializes a new instance of the <see cref="SecureStoreUnlockedFormViewModel"/> class.
/// </summary>
/// <param name="storeName">The name of the secure store.</param>
/// <param name="storePath">The full path to the secure store file.</param>
/// <param name="secretCount">The number of secrets in the store.</param>
/// <param name="hasUnsavedChanges">Whether the store has unsaved changes.</param>
/// <param name="onLockRequested">The action to invoke when lock is requested.</param>
/// <param name="onAddSecretRequested">The action to invoke when adding a new secret is requested.</param>
/// <param name="onSaveRequested">The action to invoke when save is requested.</param>
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);
}
/// <summary>
/// Gets the name of the secure store.
/// </summary>
public string StoreName { get; }
/// <summary>
/// Gets the full path to the secure store file.
/// </summary>
public string StorePath { get; }
/// <summary>
/// Gets the number of secrets in the store.
/// </summary>
public int SecretCount { get; }
/// <summary>
/// Gets or sets whether the store has unsaved changes.
/// </summary>
public bool HasUnsavedChanges
{
get => _hasUnsavedChanges;
set
{
if (SetProperty(ref _hasUnsavedChanges, value))
{
// Notify that SaveCommand's CanExecute may have changed
(SaveCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
}
}
/// <summary>
/// Gets the command to lock the store.
/// </summary>
public ICommand LockCommand { get; }
/// <summary>
/// Gets the command to add a new secret.
/// </summary>
public ICommand AddSecretCommand { get; }
/// <summary>
/// Gets the command to save the store.
/// </summary>
public ICommand SaveCommand { get; }
}
@@ -37,10 +37,6 @@ public class MainWindowViewModel : ViewModelBase
private ConfigModel? _appSettings;
private PipelinesConfigModel? _pipelines;
// SecureStore state tracking
private readonly Dictionary<string, TreeNodeViewModel> _openStores = new();
private TreeNodeViewModel? _selectedStoreNode;
/// <summary>
/// Gets or sets the currently loaded configuration folder path.
/// </summary>
@@ -149,26 +145,11 @@ public class MainWindowViewModel : ViewModelBase
/// </summary>
public ICommand AddExistingStoreCommand { get; }
/// <summary>
/// Gets the command for unlocking a secure store.
/// </summary>
public ICommand UnlockStoreCommand { get; }
/// <summary>
/// Gets the command for locking a secure store.
/// </summary>
public ICommand LockStoreCommand { get; }
/// <summary>
/// Gets the command for saving a secure store.
/// </summary>
public ICommand SaveStoreCommand { get; }
/// <summary>
/// Gets the command for locking all open secure stores.
/// </summary>
public ICommand LockAllStoresCommand { get; }
/// <summary>
/// Gets the command for generating a new key file.
/// </summary>
@@ -246,10 +227,7 @@ public class MainWindowViewModel : ViewModelBase
// 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);
@@ -300,69 +278,114 @@ public class MainWindowViewModel : ViewModelBase
if (folder != null)
{
await LoadConfigAsync(folder);
await EnsureDefaultSecureStoreAsync(folder);
}
}
/// <summary>
/// Ensures a default secure store exists and is loaded.
/// Creates one if it doesn't exist.
/// Initializes the SecureStore automatically on config load.
/// Creates the store if it doesn't exist and AutoCreateStore is true.
/// Opens the store and ensures all required keys exist.
/// </summary>
private async Task EnsureDefaultSecureStoreAsync(string configFolder)
private async Task InitializeSecureStoreAsync()
{
var defaultStorePath = Path.Combine(configFolder, "default.secrets.json");
var defaultKeyPath = Path.Combine(configFolder, "default.secrets.key");
if (_appSettings?.SecureStore == null)
return;
if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
return;
var secureStoreConfig = _appSettings.SecureStore;
// Resolve paths relative to config folder
var storePath = Path.IsPathRooted(secureStoreConfig.StorePath)
? secureStoreConfig.StorePath
: Path.Combine(ConfigFolderPath, secureStoreConfig.StorePath);
var keyFilePath = Path.IsPathRooted(secureStoreConfig.KeyFilePath)
? secureStoreConfig.KeyFilePath
: Path.Combine(ConfigFolderPath, secureStoreConfig.KeyFilePath);
try
{
// Create default store if it doesn't exist
if (!File.Exists(defaultStorePath))
if (!File.Exists(storePath))
{
_logger?.LogInformation("Creating default secure store at {Path}", defaultStorePath);
if (!secureStoreConfig.AutoCreateStore)
{
_logger?.LogWarning("SecureStore not found and AutoCreateStore is false");
return;
}
_secureStoreManager.CreateStore(defaultStorePath, defaultKeyPath);
// Create new store with keyfile
_logger?.LogInformation("Creating SecureStore at {StorePath}", storePath);
_secureStoreManager.CreateStore(storePath, keyFilePath);
// Add some example secrets
_secureStoreManager.SetSecret("jde-password", "");
_secureStoreManager.SetSecret("cms-password", "");
_secureStoreManager.SetSecret("lotfinder-password", "");
_secureStoreManager.Save();
_secureStoreManager.CloseStore();
// Update appsettings.json with actual paths
secureStoreConfig.StorePath = GetRelativePath(ConfigFolderPath, storePath);
secureStoreConfig.KeyFilePath = GetRelativePath(ConfigFolderPath, keyFilePath);
await SaveAppSettingsAsync();
}
else
{
// Open existing store
if (!File.Exists(keyFilePath))
{
if (_dialogService != null)
{
await _dialogService.ShowMessageAsync(
"SecureStore Error",
$"Key file not found: {keyFilePath}\n\nThe SecureStore cannot be opened without its key file.");
}
return;
}
// Rebuild tree to show the new store
BuildTreeNodes();
_secureStoreManager.OpenStore(storePath, keyFilePath);
}
// Auto-unlock the default store if key file exists
if (File.Exists(defaultStorePath) && File.Exists(defaultKeyPath))
// Ensure all required keys exist
if (secureStoreConfig.RequiredKeys?.Count > 0)
{
// Find the default store node in the tree
var secureStoresFolder = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStoresFolder);
var defaultStoreNode = secureStoresFolder?.Children.FirstOrDefault(n =>
n.StorePath != null && n.StorePath.EndsWith("default.secrets.json"));
if (defaultStoreNode != null)
var addedKeys = _secureStoreManager.EnsureRequiredKeys(secureStoreConfig.RequiredKeys);
if (addedKeys.Count > 0)
{
_secureStoreManager.OpenStore(defaultStorePath, defaultKeyPath);
defaultStoreNode.IsUnlocked = true;
_openStores[defaultStorePath] = defaultStoreNode;
RefreshStoreChildren(defaultStoreNode);
defaultStoreNode.IsExpanded = true;
_logger?.LogInformation("Auto-unlocked default secure store");
_logger?.LogInformation("Added {Count} missing required keys", addedKeys.Count);
}
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to initialize default secure store");
_logger?.LogError(ex, "Failed to initialize SecureStore");
if (_dialogService != null)
{
await _dialogService.ShowMessageAsync(
"SecureStore Error",
$"Failed to initialize SecureStore:\n\n{ex.Message}");
}
}
await Task.CompletedTask;
}
/// <summary>
/// Opens a folder picker dialog to select a configuration folder.
/// Gets the relative path from a base path to a full path.
/// </summary>
private static string GetRelativePath(string basePath, string fullPath)
{
var baseUri = new Uri(basePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar);
var fullUri = new Uri(fullPath);
return Uri.UnescapeDataString(baseUri.MakeRelativeUri(fullUri).ToString().Replace('/', Path.DirectorySeparatorChar));
}
/// <summary>
/// Saves the appsettings.json file.
/// </summary>
private async Task SaveAppSettingsAsync()
{
if (_appSettings == null) return;
var appSettingsPath = Path.Combine(ConfigFolderPath, "appsettings.json");
await _configFileService.SaveAppSettingsAsync(appSettingsPath, _appSettings);
}
/// <summary>
/// Opens a file picker dialog to select a configuration file.
/// </summary>
private async Task OpenFolderAsync()
{
@@ -372,10 +395,14 @@ public class MainWindowViewModel : ViewModelBase
return;
}
var folder = await _dialogService.ShowFolderPickerAsync("Select Configuration Folder");
if (folder != null)
var filePath = await _dialogService.ShowFilePickerAsync("Select Configuration File");
if (filePath != null)
{
await LoadConfigAsync(folder);
var folder = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(folder))
{
await LoadConfigAsync(folder);
}
}
}
@@ -390,15 +417,20 @@ public class MainWindowViewModel : ViewModelBase
ConfigFolderPath = folderPath;
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
var pipelinesPath = Path.Combine(folderPath, "Pipelines", "pipelines.json");
_appSettings = await _configFileService.LoadAppSettingsAsync(appSettingsPath);
// Use config-driven pipeline path
var pipelinesConfigPath = _appSettings?.Pipelines?.ConfigPath ?? "Pipelines/pipelines.json";
var pipelinesPath = Path.Combine(folderPath, pipelinesConfigPath);
if (File.Exists(pipelinesPath))
{
_pipelines = await _configFileService.LoadPipelinesAsync(pipelinesPath);
}
// Initialize SecureStore (auto-create if needed, open, sync required keys)
await InitializeSecureStoreAsync();
BuildTreeNodes();
Validate();
@@ -438,48 +470,55 @@ public class MainWindowViewModel : ViewModelBase
}
TreeNodes.Add(pipelinesFolder);
// Secure Stores folder
var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "🔑", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
DiscoverSecureStores(secureStoresFolder);
TreeNodes.Add(secureStoresFolder);
// Secure Store node (single store, secrets as direct children)
var secureStoreNode = CreateSecureStoreNode();
if (secureStoreNode != null)
{
TreeNodes.Add(secureStoreNode);
}
}
/// <summary>
/// Discovers existing secure store files in the configuration folder.
/// Creates the SecureStore tree node with secrets as direct children.
/// </summary>
/// <param name="parentNode">The parent tree node to add discovered stores to.</param>
private void DiscoverSecureStores(TreeNodeViewModel parentNode)
private TreeNodeViewModel? CreateSecureStoreNode()
{
if (_appSettings?.SecureStore == null)
return null;
if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
return;
return null;
try
var storePath = Path.IsPathRooted(_appSettings.SecureStore.StorePath)
? _appSettings.SecureStore.StorePath
: Path.Combine(ConfigFolderPath, _appSettings.SecureStore.StorePath);
var keyFilePath = Path.IsPathRooted(_appSettings.SecureStore.KeyFilePath)
? _appSettings.SecureStore.KeyFilePath
: Path.Combine(ConfigFolderPath, _appSettings.SecureStore.KeyFilePath);
var storeNode = new TreeNodeViewModel("Secure Store", "🔑", TreeNodeType.SecureStore)
{
// Look for *.secrets.json files in the config folder
var secretsFiles = Directory.GetFiles(ConfigFolderPath, "*.secrets.json", SearchOption.TopDirectoryOnly);
StorePath = storePath,
KeyFilePath = keyFilePath,
SectionKey = storePath,
IsExpanded = true
};
foreach (var filePath in secretsFiles)
// Add secrets as direct children if store is open
if (_secureStoreManager.IsStoreOpen)
{
foreach (var key in _secureStoreManager.GetKeys().OrderBy(k => k))
{
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, "🔒", TreeNodeType.SecureStore)
var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret)
{
StorePath = filePath,
SectionKey = filePath,
IsUnlocked = false
SecretKey = key
};
parentNode.Children.Add(storeNode);
storeNode.Children.Add(secretNode);
}
}
_logger?.LogDebug("Discovered {Count} secure store files", secretsFiles.Length);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to discover secure store files");
}
return storeNode;
}
/// <summary>
@@ -492,34 +531,18 @@ public class MainWindowViewModel : ViewModelBase
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);
}
SelectedFormViewModel = CreateSecureStoreInfoFormViewModel();
RaiseSecureStoreCommandsCanExecuteChanged();
return;
case TreeNodeType.Secret:
// Find the parent store node
_selectedStoreNode = FindParentStoreNode(_selectedNode);
SelectedFormViewModel = CreateSecretFormViewModel(_selectedNode);
RaiseSecureStoreCommandsCanExecuteChanged();
return;
@@ -529,11 +552,9 @@ public class MainWindowViewModel : ViewModelBase
if (_appSettings == null)
{
SelectedFormViewModel = null;
_selectedStoreNode = null;
return;
}
_selectedStoreNode = null;
SelectedFormViewModel = _selectedNode.SectionKey switch
{
"DataSync" => new DataSyncFormViewModel(_appSettings.DataSync, MarkAsChanged),
@@ -552,39 +573,15 @@ public class MainWindowViewModel : ViewModelBase
}
/// <summary>
/// Creates a form view model for a locked secure store.
/// Creates a form view model for the SecureStore info panel.
/// </summary>
private SecureStoreLockedFormViewModel CreateLockedStoreFormViewModel(TreeNodeViewModel storeNode)
private SecureStoreInfoFormViewModel CreateSecureStoreInfoFormViewModel()
{
DateTime? lastModified = null;
if (!string.IsNullOrEmpty(storeNode.StorePath) && File.Exists(storeNode.StorePath))
{
lastModified = File.GetLastWriteTime(storeNode.StorePath);
}
var storePath = _appSettings?.SecureStore?.StorePath ?? "Unknown";
var keyFilePath = _appSettings?.SecureStore?.KeyFilePath ?? "Unknown";
var secretCount = _secureStoreManager.IsStoreOpen ? _secureStoreManager.GetKeys().Count : 0;
return new SecureStoreLockedFormViewModel(
storeNode.Name,
storeNode.StorePath ?? string.Empty,
lastModified,
() => _ = UnlockStoreAsync());
}
/// <summary>
/// Creates a form view model for an unlocked secure store.
/// </summary>
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());
return new SecureStoreInfoFormViewModel(storePath, keyFilePath, secretCount);
}
/// <summary>
@@ -628,34 +625,12 @@ public class MainWindowViewModel : ViewModelBase
}
}
/// <summary>
/// Finds the parent store node for a secret node.
/// </summary>
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;
}
/// <summary>
/// Raises CanExecuteChanged for all SecureStore commands.
/// </summary>
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();
}
@@ -705,7 +680,8 @@ public class MainWindowViewModel : ViewModelBase
// Save pipelines if loaded
if (_pipelines != null)
{
var pipelinesPath = Path.Combine(ConfigFolderPath, "Pipelines", "pipelines.json");
var pipelinesConfigPath = _appSettings?.Pipelines?.ConfigPath ?? "Pipelines/pipelines.json";
var pipelinesPath = Path.Combine(ConfigFolderPath, pipelinesConfigPath);
if (File.Exists(pipelinesPath))
{
await _backupService.CreateBackupAsync(pipelinesPath);
@@ -932,33 +908,12 @@ public class MainWindowViewModel : ViewModelBase
#region SecureStore Commands
/// <summary>
/// Determines whether a store can be unlocked.
/// </summary>
private bool CanUnlockStore()
{
return _selectedStoreNode != null
&& _selectedStoreNode.NodeType == TreeNodeType.SecureStore
&& !_selectedStoreNode.IsUnlocked;
}
/// <summary>
/// Determines whether a store can be locked.
/// </summary>
private bool CanLockStore()
{
return _selectedStoreNode != null
&& _selectedStoreNode.NodeType == TreeNodeType.SecureStore
&& _selectedStoreNode.IsUnlocked;
}
/// <summary>
/// Determines whether the current store can be saved.
/// </summary>
private bool CanSaveStore()
{
return _selectedStoreNode != null
&& _selectedStoreNode.IsUnlocked
return _secureStoreManager.IsStoreOpen
&& _secureStoreManager.HasUnsavedChanges;
}
@@ -967,8 +922,7 @@ public class MainWindowViewModel : ViewModelBase
/// </summary>
private bool CanAddSecret()
{
return _selectedStoreNode != null
&& _selectedStoreNode.IsUnlocked;
return _secureStoreManager.IsStoreOpen;
}
/// <summary>
@@ -978,8 +932,7 @@ public class MainWindowViewModel : ViewModelBase
{
return _selectedNode != null
&& _selectedNode.NodeType == TreeNodeType.Secret
&& _selectedStoreNode != null
&& _selectedStoreNode.IsUnlocked;
&& _secureStoreManager.IsStoreOpen;
}
/// <summary>
@@ -1018,105 +971,6 @@ public class MainWindowViewModel : ViewModelBase
"File picker for existing stores not yet implemented.");
}
/// <summary>
/// Unlocks the currently selected secure store.
/// </summary>
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));
}
}
/// <summary>
/// Locks the currently selected secure store.
/// </summary>
private void LockStore()
{
if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked)
return;
LockStoreInternal(_selectedStoreNode);
OnSelectedNodeChanged();
}
/// <summary>
/// Internal method to lock a store and clean up.
/// </summary>
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);
}
/// <summary>
/// Saves the currently open secure store.
/// </summary>
@@ -1147,21 +1001,6 @@ public class MainWindowViewModel : ViewModelBase
}
}
/// <summary>
/// Locks all open secure stores.
/// </summary>
private void LockAllStores()
{
var openStoresCopy = _openStores.Values.ToList();
foreach (var storeNode in openStoresCopy)
{
LockStoreInternal(storeNode);
}
OnSelectedNodeChanged();
_logger?.LogInformation("All stores locked");
}
/// <summary>
/// Generates a new key file.
/// </summary>
@@ -1184,7 +1023,7 @@ public class MainWindowViewModel : ViewModelBase
/// </summary>
private async Task AddSecretAsync()
{
if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked)
if (!_secureStoreManager.IsStoreOpen)
return;
if (_dialogService == null)
@@ -1227,13 +1066,14 @@ public class MainWindowViewModel : ViewModelBase
{
_secureStoreManager.RemoveSecret(_selectedNode.SecretKey);
// Remove from tree
if (_selectedStoreNode != null)
// Find the Secure Store node and remove the secret from its children
var secureStoreNode = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStore);
if (secureStoreNode != null)
{
_selectedStoreNode.Children.Remove(_selectedNode);
secureStoreNode.Children.Remove(_selectedNode);
// Select the parent store node
SelectedNode = _selectedStoreNode;
SelectedNode = secureStoreNode;
}
_logger?.LogInformation("Secret deleted: {Key}", _selectedNode.SecretKey);
@@ -7,8 +7,7 @@ public enum TreeNodeType
Folder,
SettingsSection,
Pipeline,
SecureStoresFolder, // The "Secure Stores" folder
SecureStore, // Individual store files
SecureStore, // The single secure store node
Secret // Individual secrets within a store
}
@@ -29,7 +28,6 @@ public class TreeNodeViewModel : ViewModelBase
private bool _isExpanded;
private bool _isSelected;
private ValidationState _validationState = ValidationState.Unknown;
private bool _isUnlocked;
/// <summary>
/// Gets the name of the tree node.
@@ -51,39 +49,18 @@ public class TreeNodeViewModel : ViewModelBase
/// </summary>
public string? SectionKey { get; init; }
/// <summary>
/// Gets or sets whether this secure store is currently unlocked.
/// Only applicable for SecureStore node types.
/// </summary>
public bool IsUnlocked
{
get => _isUnlocked;
set
{
if (SetProperty(ref _isUnlocked, value))
{
OnPropertyChanged(nameof(LockIcon));
OnPropertyChanged(nameof(IsLocked));
}
}
}
/// <summary>
/// Gets whether this secure store is locked.
/// </summary>
public bool IsLocked => !IsUnlocked;
/// <summary>
/// Gets the lock icon for secure store nodes.
/// </summary>
public string LockIcon => IsUnlocked ? "🔓" : "🔒";
/// <summary>
/// Gets or sets the full path to the secure store file.
/// Only applicable for SecureStore node types.
/// </summary>
public string? StorePath { get; init; }
/// <summary>
/// Gets or sets the full path to the key file for the secure store.
/// Only applicable for SecureStore node types.
/// </summary>
public string? KeyFilePath { get; init; }
/// <summary>
/// Gets or sets the secret key name.
/// Only applicable for Secret node types.
@@ -13,43 +13,6 @@
<Border Height="1" Background="#2D3540" Margin="0,12,0,0"/>
</StackPanel>
<!-- Sheet Protection Section -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel Spacing="16">
<TextBlock Text="Sheet Protection" Foreground="#E6EDF5"
FontWeight="SemiBold" FontSize="14"/>
<!-- Criteria Sheet Password -->
<StackPanel Spacing="4">
<TextBlock Text="Criteria Sheet Password"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding CriteriaSheetPassword}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
PasswordChar="*"
Watermark="(optional)"/>
<TextBlock Text="Password to protect the search criteria sheet"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Data Sheet Password -->
<StackPanel Spacing="4">
<TextBlock Text="Data Sheet Password"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding DataSheetPassword}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
PasswordChar="*"
Watermark="(optional)"/>
<TextBlock Text="Password to protect the data sheet"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Output Format Section -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
@@ -92,31 +55,17 @@
<TextBlock Text="Timezone" Foreground="#E6EDF5"
FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="*,16,Auto">
<!-- Timezone ID -->
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Timezone ID"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding TimezoneId}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="America/Chicago"/>
</StackPanel>
<!-- Timezone Abbreviation -->
<StackPanel Grid.Column="2" Spacing="4" Width="100">
<TextBlock Text="Abbreviation"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding TimezoneAbbreviation}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="CST"/>
</StackPanel>
</Grid>
<TextBlock Text="Used for converting UTC timestamps to local time in exports"
Foreground="#5C6A7A" FontSize="11"/>
<StackPanel Spacing="4">
<TextBlock Text="Timezone"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<ComboBox ItemsSource="{Binding AvailableTimezones}"
SelectedItem="{Binding SelectedTimezone}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
HorizontalAlignment="Stretch"/>
<TextBlock Text="Used for converting UTC timestamps to local time in exports"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</StackPanel>
</Border>
@@ -0,0 +1,75 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
x:Class="JdeScoping.ConfigManager.Views.Forms.SecureStoreInfoFormView"
x:DataType="vm:SecureStoreInfoFormViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="24" MaxWidth="600">
<!-- Header -->
<StackPanel>
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="M12.65 10C11.83 7.67 9.61 6 7 6C3.69 6 1 8.69 1 12C1 15.31 3.69 18 7 18C9.61 18 11.83 16.33 12.65 14H17V18H21V14H23V10H12.65M7 14C5.9 14 5 13.1 5 12C5 10.9 5.9 10 7 10C8.1 10 9 10.9 9 12C9 13.1 8.1 14 7 14Z"
Width="24" Height="24" Foreground="#F59E0B"/>
<TextBlock Text="Secure Store"
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
<Border Height="1" Background="#2D3540" Margin="0,12,0,0"/>
</StackPanel>
<!-- Instructions Card -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel Spacing="12">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="M11 7V9H13V7H11M14 17V15H13V11H10V13H11V15H10V17H14M22 12C22 17.5 17.5 22 12 22C6.5 22 2 17.5 2 12C2 6.5 6.5 2 12 2C17.5 2 22 6.5 22 12Z"
Width="20" Height="20" Foreground="#3B82F6"/>
<TextBlock Text="{Binding InstructionText}"
Foreground="#E6EDF5" FontSize="14"
VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Store Info Card -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel Spacing="16">
<TextBlock Text="Store Information" Foreground="#E6EDF5"
FontWeight="SemiBold" FontSize="14"/>
<!-- Store Path -->
<StackPanel Spacing="4">
<TextBlock Text="Store Path"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding StorePath}"
Background="#1A1F26" Foreground="#9BA8B8"
BorderBrush="#2D3540" Height="36"
FontFamily="JetBrains Mono" FontSize="12"
IsReadOnly="True"/>
</StackPanel>
<!-- Key File Path -->
<StackPanel Spacing="4">
<TextBlock Text="Key File Path"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding KeyFilePath}"
Background="#1A1F26" Foreground="#9BA8B8"
BorderBrush="#2D3540" Height="36"
FontFamily="JetBrains Mono" FontSize="12"
IsReadOnly="True"/>
</StackPanel>
<!-- Secret Count -->
<StackPanel Spacing="4">
<TextBlock Text="Number of Secrets"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="{Binding SecretCount}"
Foreground="#E6EDF5" FontSize="14"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -2,9 +2,9 @@ using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Forms;
public partial class SecureStoreLockedFormView : UserControl
public partial class SecureStoreInfoFormView : UserControl
{
public SecureStoreLockedFormView()
public SecureStoreInfoFormView()
{
InitializeComponent();
}
@@ -1,82 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
x:Class="JdeScoping.ConfigManager.Views.Forms.SecureStoreLockedFormView"
x:DataType="vm:SecureStoreLockedFormViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="24" MaxWidth="600">
<!-- Header with Lock Icon -->
<StackPanel>
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="M12 17C10.89 17 10 16.1 10 15C10 13.89 10.89 13 12 13C13.11 13 14 13.89 14 15C14 16.1 13.11 17 12 17M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H7V6C7 3.24 9.24 1 12 1C14.76 1 17 3.24 17 6V8H18M12 3C10.34 3 9 4.34 9 6V8H15V6C15 4.34 13.66 3 12 3Z"
Width="24" Height="24" Foreground="#FFB84D"/>
<TextBlock Text="{Binding StoreName}"
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
<Border Height="1" Background="#2D3540" Margin="0,12,0,0"/>
</StackPanel>
<!-- Lock Status Card -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel Spacing="16">
<!-- Status Message -->
<StackPanel Orientation="Horizontal" Spacing="12" HorizontalAlignment="Center">
<PathIcon Data="M12 17C10.89 17 10 16.1 10 15C10 13.89 10.89 13 12 13C13.11 13 14 13.89 14 15C14 16.1 13.11 17 12 17M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H7V6C7 3.24 9.24 1 12 1C14.76 1 17 3.24 17 6V8H18M12 3C10.34 3 9 4.34 9 6V8H15V6C15 4.34 13.66 3 12 3Z"
Width="48" Height="48" Foreground="#5C6A7A"/>
</StackPanel>
<TextBlock Text="This store is locked"
Foreground="#9BA8B8" FontSize="14" FontWeight="Medium"
HorizontalAlignment="Center"/>
<TextBlock Text="Enter your password to unlock and view secrets"
Foreground="#5C6A7A" FontSize="12"
HorizontalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Store Information Card -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel Spacing="16">
<TextBlock Text="Store Information" Foreground="#E6EDF5"
FontWeight="SemiBold" FontSize="14"/>
<!-- Store Path -->
<StackPanel Spacing="4">
<TextBlock Text="Store Path"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding StorePath}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono" FontSize="11"
IsReadOnly="True"/>
</StackPanel>
<!-- Last Modified -->
<StackPanel Spacing="4" IsVisible="{Binding LastModified, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="Last Modified"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="{Binding LastModified, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}"
Foreground="#E6EDF5" FontSize="13"
FontFamily="JetBrains Mono"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Unlock Button -->
<Button Command="{Binding UnlockCommand}"
HorizontalAlignment="Center"
Background="#3B82F6" Foreground="#FFFFFF"
Padding="24,12" CornerRadius="6"
FontWeight="SemiBold">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H17V6C17 3.24 14.76 1 12 1C10.07 1 8.39 2.11 7.53 3.71L6.14 2.43C7.34 0.88 9.54 0 12 0C15.87 0 19 3.13 19 7V8H18M12 17C13.11 17 14 16.1 14 15C14 13.89 13.11 13 12 13C10.89 13 10 13.89 10 15C10 16.1 10.89 17 12 17Z"
Width="16" Height="16" Foreground="#FFFFFF"/>
<TextBlock Text="Unlock Store..."/>
</StackPanel>
</Button>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -1,125 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
x:Class="JdeScoping.ConfigManager.Views.Forms.SecureStoreUnlockedFormView"
x:DataType="vm:SecureStoreUnlockedFormViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="24" MaxWidth="600">
<!-- Header with Unlock Icon -->
<StackPanel>
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H17V6C17 3.24 14.76 1 12 1C10.07 1 8.39 2.11 7.53 3.71L5.71 2.39C6.83 0.92 9.15 0 12 0C15.87 0 19 3.13 19 7V8H18M12 17C13.11 17 14 16.1 14 15C14 13.89 13.11 13 12 13C10.89 13 10 13.89 10 15C10 16.1 10.89 17 12 17Z"
Width="24" Height="24" Foreground="#22C55E"/>
<TextBlock Text="{Binding StoreName}"
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
<Border Height="1" Background="#2D3540" Margin="0,12,0,0"/>
</StackPanel>
<!-- Store Status Card -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel Spacing="16">
<TextBlock Text="Store Status" Foreground="#E6EDF5"
FontWeight="SemiBold" FontSize="14"/>
<!-- Store Path -->
<StackPanel Spacing="4">
<TextBlock Text="Store Path"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding StorePath}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono" FontSize="11"
IsReadOnly="True"/>
</StackPanel>
<!-- Secret Count -->
<StackPanel Spacing="4">
<TextBlock Text="Secrets"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding SecretCount}"
Foreground="#E6EDF5" FontSize="24" FontWeight="Bold"
VerticalAlignment="Center"/>
<TextBlock Text="secret(s) stored"
Foreground="#5C6A7A" FontSize="13"
VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
<!-- Unsaved Changes Status -->
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="12">
<StackPanel Orientation="Horizontal" Spacing="8">
<!-- Status indicator when no unsaved changes -->
<PathIcon Data="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z"
Width="20" Height="20" Foreground="#22C55E"
IsVisible="{Binding !HasUnsavedChanges}"/>
<TextBlock Text="All changes saved"
Foreground="#22C55E" FontSize="13" FontWeight="Medium"
VerticalAlignment="Center"
IsVisible="{Binding !HasUnsavedChanges}"/>
<!-- Status indicator when there are unsaved changes -->
<PathIcon Data="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2M13 17H11V15H13V17M13 13H11V7H13V13Z"
Width="20" Height="20" Foreground="#FFB84D"
IsVisible="{Binding HasUnsavedChanges}"/>
<TextBlock Text="Unsaved changes"
Foreground="#FFB84D" FontSize="13" FontWeight="Medium"
VerticalAlignment="Center"
IsVisible="{Binding HasUnsavedChanges}"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- Actions Card -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel Spacing="16">
<TextBlock Text="Actions" Foreground="#E6EDF5"
FontWeight="SemiBold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="12">
<!-- Lock Store Button -->
<Button Command="{Binding LockCommand}"
Background="#3D4550" Foreground="#E6EDF5"
Padding="16,10" CornerRadius="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="M12 17C10.89 17 10 16.1 10 15C10 13.89 10.89 13 12 13C13.11 13 14 13.89 14 15C14 16.1 13.11 17 12 17M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H7V6C7 3.24 9.24 1 12 1C14.76 1 17 3.24 17 6V8H18M12 3C10.34 3 9 4.34 9 6V8H15V6C15 4.34 13.66 3 12 3Z"
Width="16" Height="16" Foreground="#E6EDF5"/>
<TextBlock Text="Lock Store"/>
</StackPanel>
</Button>
<!-- Add Secret Button -->
<Button Command="{Binding AddSecretCommand}"
Background="#22C55E" Foreground="#FFFFFF"
Padding="16,10" CornerRadius="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="M19 13H13V19H11V13H5V11H11V5H13V11H19V13Z"
Width="16" Height="16" Foreground="#FFFFFF"/>
<TextBlock Text="Add Secret"/>
</StackPanel>
</Button>
<!-- Save Button -->
<Button Command="{Binding SaveCommand}"
Background="#3B82F6" Foreground="#FFFFFF"
Padding="16,10" CornerRadius="6"
IsEnabled="{Binding HasUnsavedChanges}">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="M15 9H5V5H15M12 19C10.34 19 9 17.66 9 16C9 14.34 10.34 13 12 13C13.66 13 15 14.34 15 16C15 17.66 13.66 19 12 19M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3Z"
Width="16" Height="16" Foreground="#FFFFFF"/>
<TextBlock Text="Save"/>
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Forms;
public partial class SecureStoreUnlockedFormView : UserControl
{
public SecureStoreUnlockedFormView()
{
InitializeComponent();
}
}
@@ -59,31 +59,6 @@ public class SecureStoreManagerTests : IDisposable
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<ArgumentException>(() => _sut.CreateStoreWithPassword(storePath, ""));
}
[Fact]
public void OpenStore_WithValidKeyFile_OpensStore()
{
@@ -112,22 +87,6 @@ public class SecureStoreManagerTests : IDisposable
Should.Throw<FileNotFoundException>(() => _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()
{
@@ -11,28 +11,45 @@ public class ExcelExportFormViewModelTests
// Arrange
var model = new ExcelExportSection
{
CriteriaSheetPassword = "criteriaPass123",
DataSheetPassword = "dataPass456",
MaxRowsPerSheet = 500000,
DefaultDateFormat = "MM/dd/yyyy",
DebugWriteToFile = true,
DebugOutputDirectory = "/tmp/debug",
TimezoneId = "America/New_York",
TimezoneAbbreviation = "ET"
TimezoneId = "America/Los_Angeles"
};
// Act
var sut = new ExcelExportFormViewModel(model, () => { });
// Assert
sut.CriteriaSheetPassword.ShouldBe("criteriaPass123");
sut.DataSheetPassword.ShouldBe("dataPass456");
sut.MaxRowsPerSheet.ShouldBe(500000);
sut.DefaultDateFormat.ShouldBe("MM/dd/yyyy");
sut.DebugWriteToFile.ShouldBeTrue();
sut.DebugOutputDirectory.ShouldBe("/tmp/debug");
sut.TimezoneId.ShouldBe("America/New_York");
sut.TimezoneAbbreviation.ShouldBe("ET");
sut.SelectedTimezone.ShouldBe("America/Los_Angeles");
}
[Fact]
public void Constructor_UsesModelTimezone()
{
// Arrange - model defaults to "America/Chicago"
var model = new ExcelExportSection();
// Act
var sut = new ExcelExportFormViewModel(model, () => { });
// Assert
sut.SelectedTimezone.ShouldBe("America/Chicago");
}
[Fact]
public void AvailableTimezones_ContainsSystemTimezones()
{
// Act & Assert
ExcelExportFormViewModel.AvailableTimezones.ShouldNotBeEmpty();
// Check for common IANA timezones
ExcelExportFormViewModel.AvailableTimezones.ShouldContain("America/Chicago");
ExcelExportFormViewModel.AvailableTimezones.ShouldContain("UTC");
}
[Fact]
@@ -49,16 +66,30 @@ public class ExcelExportFormViewModelTests
model.MaxRowsPerSheet.ShouldBe(750000);
}
[Fact]
public void SelectedTimezone_UpdatesModelTimezoneId()
{
// Arrange - model defaults to "America/Chicago"
var model = new ExcelExportSection();
var sut = new ExcelExportFormViewModel(model, () => { });
// Act - change to a different timezone
sut.SelectedTimezone = "America/New_York";
// Assert
model.TimezoneId.ShouldBe("America/New_York");
}
[Fact]
public void PropertyChange_InvokesOnChanged()
{
// Arrange
var model = new ExcelExportSection();
var model = new ExcelExportSection(); // Default TimezoneId is "America/Chicago"
var changedInvoked = false;
var sut = new ExcelExportFormViewModel(model, () => changedInvoked = true);
// Act
sut.TimezoneId = "Europe/London";
// Act - change to a different timezone than the default
sut.SelectedTimezone = "America/Denver";
// Assert
changedInvoked.ShouldBeTrue();
@@ -1,115 +0,0 @@
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<ArgumentNullException>(() =>
new SecureStoreLockedFormViewModel(null!, "/path", null, () => { }));
}
[Fact]
public void Constructor_ThrowsOnNullStorePath()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SecureStoreLockedFormViewModel("test", null!, null, () => { }));
}
[Fact]
public void Constructor_ThrowsOnNullUnlockCallback()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
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();
}
}
@@ -1,327 +0,0 @@
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<ArgumentNullException>(() =>
new SecureStoreUnlockedFormViewModel(null!, "/path", 0, false, () => { }, () => { }, () => { }));
}
[Fact]
public void Constructor_ThrowsOnNullStorePath()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SecureStoreUnlockedFormViewModel("test", null!, 0, false, () => { }, () => { }, () => { }));
}
[Fact]
public void Constructor_ThrowsOnNullLockCallback()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, null!, () => { }, () => { }));
}
[Fact]
public void Constructor_ThrowsOnNullAddSecretCallback()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, null!, () => { }));
}
[Fact]
public void Constructor_ThrowsOnNullSaveCallback()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
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();
}
}
@@ -157,7 +157,7 @@ public class MainWindowViewModelTests
// Assert
sut.SelectedFormViewModel.ShouldBeOfType<ExcelExportFormViewModel>();
((ExcelExportFormViewModel)sut.SelectedFormViewModel!).TimezoneId.ShouldBe("America/New_York");
((ExcelExportFormViewModel)sut.SelectedFormViewModel!).SelectedTimezone.ShouldBe("America/New_York");
}
[Fact]
@@ -281,9 +281,11 @@ public class MainWindowViewModelTests
sut.LoadConfigForTesting(config, null);
// Assert
sut.TreeNodes.Count.ShouldBe(3); // Settings, Pipelines, and Secure Stores folders
// Without a configured/open SecureStore, only Settings and Pipelines appear
sut.TreeNodes.Count.ShouldBe(2); // Settings, Pipelines (no Secure Store when not configured)
sut.TreeNodes[0].Name.ShouldBe("Settings");
sut.TreeNodes[0].Children.Count.ShouldBe(6); // DataSync, DataAccess, Auth, Ldap, Search, ExcelExport
sut.TreeNodes[1].Name.ShouldBe("Pipelines");
}
[Fact]
@@ -309,6 +311,65 @@ public class MainWindowViewModelTests
sut.TreeNodes[1].Children.Count.ShouldBe(2);
}
[Fact]
public async Task OpenFolderCommand_UsesFilePicker_AndDerivesFolder()
{
// Arrange
var expectedFilePath = "/path/to/folder/appsettings.json";
var expectedFolder = "/path/to/folder";
var config = new ConfigModel();
// Ensure auto-discovery doesn't load config
_autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null);
_dialogService.ShowFilePickerAsync(Arg.Any<string?>())
.Returns(expectedFilePath);
_configFileService.LoadAppSettingsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(config);
var sut = CreateViewModel();
// Wait for constructor async init to complete
await Task.Delay(50);
_configFileService.ClearReceivedCalls();
// Act
sut.OpenFolderCommand.Execute(null);
// Give async command time to complete
await Task.Delay(100);
// Assert
await _dialogService.Received(1).ShowFilePickerAsync("Select Configuration File");
await _configFileService.Received(1).LoadAppSettingsAsync(
Arg.Is<string>(s => s.Contains(expectedFolder)),
Arg.Any<CancellationToken>());
sut.ConfigFolderPath.ShouldBe(expectedFolder);
}
[Fact]
public async Task OpenFolderCommand_WhenCancelled_DoesNotLoadConfig()
{
// Arrange
// Ensure auto-discovery doesn't load config
_autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null);
_dialogService.ShowFilePickerAsync(Arg.Any<string?>())
.Returns((string?)null);
var sut = CreateViewModel();
var originalPath = sut.ConfigFolderPath;
// Wait for constructor async init to complete
await Task.Delay(50);
_configFileService.ClearReceivedCalls();
// Act
sut.OpenFolderCommand.Execute(null);
// Give async command time to complete
await Task.Delay(100);
// Assert
await _dialogService.Received(1).ShowFilePickerAsync(Arg.Any<string?>());
await _configFileService.DidNotReceive().LoadAppSettingsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
sut.ConfigFolderPath.ShouldBe(originalPath);
}
private MainWindowViewModel CreateViewModel()
{
return new MainWindowViewModel(
@@ -222,7 +222,6 @@ public class TreeNodeViewModelTests
#region SecureStore Node Type Tests
[Theory]
[InlineData(TreeNodeType.SecureStoresFolder)]
[InlineData(TreeNodeType.SecureStore)]
[InlineData(TreeNodeType.Secret)]
public void Constructor_WithSecureStoreNodeTypes_SetsNodeTypeCorrectly(TreeNodeType nodeType)
@@ -234,119 +233,6 @@ public class TreeNodeViewModelTests
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()
{
@@ -0,0 +1,216 @@
# Regex Transformer Design
**Date:** 2025-01-22
**Status:** Approved
**Author:** Claude + User collaboration
## Overview
Add a new `RegexTransformer` to the DataSync ETL pipeline that transforms string values in a column using regular expressions. Includes a custom editor for the ConfigManager with a live test/preview feature.
## Requirements
1. **Two transformation modes:**
- **Find & Replace** - Replace matched text with replacement string
- **Match & Extract** - Extract first capture group from pattern
2. **Single column per transformer** - Each transformer operates on one column; add multiple transformers for multiple columns
3. **Configurable non-match behavior:**
- Keep original value (default)
- Return null
- Return empty string
4. **Case-insensitive option** - Optional flag for case-insensitive matching
5. **Test/preview in editor** - Users can test their regex pattern against sample input before saving
## Architecture
### Core Transformer
**File:** `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs`
```csharp
public enum NonMatchBehavior
{
KeepOriginal,
ReturnNull,
ReturnEmpty
}
public class RegexTransformer : DataTransformerBase
{
public RegexTransformer(
string columnName,
string pattern,
string? replacement = null, // null = Match & Extract mode
bool ignoreCase = false,
NonMatchBehavior nonMatchBehavior = NonMatchBehavior.KeepOriginal)
}
```
**Behavior:**
- Extends `DataTransformerBase`
- In `OnInitialize()`: finds column ordinal, compiles regex
- Overrides `GetValue()`: transforms target column, passes through others
- Mode determined by `replacement` parameter: non-null = Find & Replace, null = Match & Extract
**Transformation logic:**
1. If not target column → pass through
2. If null/DBNull → pass through
3. Find & Replace mode: `regex.Replace(value, replacement)`
4. Match & Extract mode: return `match.Groups[1].Value` if match, else apply `NonMatchBehavior`
### Configuration Model
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs`
Add enum:
```csharp
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NonMatchBehavior
{
KeepOriginal,
ReturnNull,
ReturnEmpty
}
```
Add properties to `TransformerModel`:
```csharp
public string? ColumnName { get; set; }
public string? Pattern { get; set; }
public string? Replacement { get; set; }
public bool IgnoreCase { get; set; }
public NonMatchBehavior NonMatchBehavior { get; set; } = NonMatchBehavior.KeepOriginal;
```
**JSON example:**
```json
{
"Type": "Regex",
"ColumnName": "BatchID",
"Pattern": "^IIS_",
"Replacement": "",
"IgnoreCase": true,
"NonMatchBehavior": "KeepOriginal"
}
```
### ViewModel
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs`
Add `RegexTransformerViewModel` class:
**Core properties (bound to config):**
- `ColumnName` (string)
- `Pattern` (string)
- `Replacement` (string)
- `IgnoreCase` (bool)
- `NonMatchBehavior` (enum)
**Mode properties (computed):**
- `IsFindReplaceMode` / `IsMatchExtractMode` - for radio button binding
- `PatternHelpText` - changes based on mode
**Test feature properties:**
- `TestInput` (string)
- `TestResultValue` (string)
- `TestResultLabel` (string) - "Output" or "No Match"
- `TestResultIcon` (string) - "✓" or "—"
- `TestResultBackground` (string) - green or orange
- `HasTestResult` / `HasTestError` (bool)
- `TestErrorMessage` (string)
**Command:**
- `TestPatternCommand` - executes regex test
### Editor View
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml`
Layout sections:
1. **Header** - Title "Regex Transformer" + description
2. **Column Name** - Text input with required indicator
3. **Mode Selection** - Radio buttons: "Find & Replace" / "Match & Extract"
4. **Pattern** - Monospace text input with dynamic help text
5. **Replacement** - Monospace text input (visible only in Find & Replace mode)
6. **Options Row** - Case Insensitive checkbox + Non-Match Behavior dropdown
7. **Test Section** - Bordered area with:
- Sample input textbox
- Test button (blue)
- Result display with status icon (green checkmark / orange dash)
- Error display (red-tinted) for invalid patterns
8. **Help Box** - Pattern examples
**Design tokens (matching existing editors):**
- Background: `#0D0F12`, `#151920`, `#232A35`
- Text: `#E6EDF5` (bright), `#9BA8B8` (labels), `#5C6A7A` (dim)
- Accent: `#3B82F6` (blue button), `#22C55E` (success), `#F59E0B` (warning), `#FF6B6B` (error)
- Font: JetBrains Mono for code fields
- Spacing: 16px between sections, 4px within field groups
### Registration
**TransformerFactory updates:**
```csharp
// Create() switch:
"regex" => new RegexTransformerViewModel(model, onChanged),
// CreateNew() switch:
"regex" => new RegexTransformerViewModel(onChanged),
// AvailableTypes:
["ColumnDrop", "ColumnRename", "JdeDate", "Regex"]
```
**MainWindow.axaml DataTemplate:**
```xml
<DataTemplate DataType="{x:Type steps:RegexTransformerViewModel}">
<editors:RegexEditorView/>
</DataTemplate>
```
## Testing Strategy
### RegexTransformer Unit Tests
| Test Case | Description |
|-----------|-------------|
| `FindReplace_RemovesPrefix` | `^IIS_` + `""``IIS_12345` becomes `12345` |
| `FindReplace_ReplacesWithText` | `foo` + `bar``foo123` becomes `bar123` |
| `FindReplace_UseCaptureGroups` | `(\d+)-(\d+)` + `$2-$1` swaps groups |
| `MatchExtract_ExtractsFirstGroup` | `ID_(\d+)` extracts `12345` from `ID_12345` |
| `MatchExtract_NoMatch_KeepOriginal` | Returns original when no match |
| `MatchExtract_NoMatch_ReturnNull` | Returns DBNull when configured |
| `MatchExtract_NoMatch_ReturnEmpty` | Returns `""` when configured |
| `IgnoreCase_MatchesDifferentCase` | `^iis_` matches `IIS_12345` |
| `NullValue_PassesThrough` | DBNull input returns DBNull |
| `NonTargetColumn_Unchanged` | Other columns pass through |
| `InvalidRegex_ThrowsOnInitialize` | Bad pattern throws meaningful exception |
### RegexTransformerViewModel Unit Tests
| Test Case | Description |
|-----------|-------------|
| `TestPattern_ValidRegex_ShowsResult` | Test button displays transformed output |
| `TestPattern_InvalidRegex_ShowsError` | Bad pattern shows error message |
| `ModeSwitch_UpdatesHelpText` | Help text changes with mode |
| `ToModel_SerializesCorrectly` | ViewModel produces valid TransformerModel |
| `FromModel_LoadsAllProperties` | Constructor populates from existing config |
## Files Summary
**Create:**
- `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs`
- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml`
- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml.cs`
- `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs`
- `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/RegexTransformerViewModelTests.cs`
**Modify:**
- `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs` - Add enum + properties
- `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs` - Add ViewModel + factory
- `NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml` - Add DataTemplate
File diff suppressed because it is too large Load Diff
+525
View File
@@ -0,0 +1,525 @@
# Implementation Plan: SecureStore Auto-Initialization & Tree Flattening
## Overview
This plan implements automatic SecureStore initialization on config load and flattens the tree hierarchy from "Secure Stores > secrets > [keys]" to "Secure Store > [keys]".
## Design Decisions (from brainstorming)
1. **Auto-creation**: Fully automatic on config folder load when `AutoCreateStore=true`
2. **Path updates**: Write actual paths back to appsettings.json after creating files
3. **Tree structure**: Single "Secure Store" node with secrets as direct children
4. **Authentication**: Keyfile-only (remove password option entirely)
5. **Lock concept**: Removed - store is always open while config folder is loaded
6. **Icon**: Key icon (🔑) for "Secure Store" node
7. **Right panel**: Show instructions "Select a secret to edit" when store node selected
8. **Error handling**: Show error dialog if keyfile missing/corrupted
---
## Task 1: Update TreeNodeType enum
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs`
**Changes:**
- Remove `SecureStoresFolder` enum value (no longer needed)
- Keep `SecureStore` for the single store node
- Keep `Secret` for individual secrets
**Before (lines 5-13):**
```csharp
public enum TreeNodeType
{
Folder,
SettingsSection,
Pipeline,
SecureStoresFolder, // The "Secure Stores" folder
SecureStore, // Individual store files
Secret // Individual secrets within a store
}
```
**After:**
```csharp
public enum TreeNodeType
{
Folder,
SettingsSection,
Pipeline,
SecureStore, // The single secure store node
Secret // Individual secrets within a store
}
```
---
## Task 2: Remove lock-related properties from TreeNodeViewModel
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs`
**Remove these properties (lines 55-79):**
- `IsUnlocked` property and backing field
- `IsLocked` computed property
- `LockIcon` computed property
**Keep:**
- `StorePath` and `KeyFilePath` (needed for store identification)
- `SecretKey` (needed for secret nodes)
---
## Task 3: Remove password-related methods from ISecureStoreManager
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs`
**Remove these methods:**
- `CreateStoreWithPassword(string storePath, string password)` (lines 30-35)
- `OpenStoreWithPassword(string storePath, string password)` (lines 44-49)
---
## Task 4: Remove password-related methods from SecureStoreManager
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs`
**Remove these methods:**
- `CreateStoreWithPassword()` (lines 75-96)
- `OpenStoreWithPassword()` (lines 120-140)
---
## Task 5: Add EnsureRequiredKeys method to ISecureStoreManager
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs`
**Add new method:**
```csharp
/// <summary>
/// Ensures all required keys exist in the store, creating blank values for any missing keys.
/// </summary>
/// <param name="requiredKeys">List of keys that must exist.</param>
/// <returns>List of keys that were added.</returns>
IReadOnlyList<string> EnsureRequiredKeys(IEnumerable<string> requiredKeys);
```
---
## Task 6: Implement EnsureRequiredKeys in SecureStoreManager
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs`
**Add implementation:**
```csharp
/// <inheritdoc />
public IReadOnlyList<string> EnsureRequiredKeys(IEnumerable<string> requiredKeys)
{
ThrowIfDisposed();
if (_secretsManager == null)
throw new InvalidOperationException("No store is currently open.");
var addedKeys = new List<string>();
foreach (var key in requiredKeys)
{
if (!_keys.Contains(key))
{
_logger.LogInformation("Adding missing required key: {Key}", key);
SetSecret(key, string.Empty);
addedKeys.Add(key);
}
}
if (addedKeys.Count > 0)
{
Save();
}
return addedKeys.AsReadOnly();
}
```
---
## Task 7: Delete locked/unlocked form files
**Delete these files entirely:**
ViewModels:
- `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs`
- `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs`
Views:
- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml`
- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs`
- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml`
- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs`
---
## Task 8: Create SecureStoreInfoFormViewModel
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs` (new file)
**Content:**
```csharp
namespace JdeScoping.ConfigManager.ViewModels.Forms;
/// <summary>
/// View model for the SecureStore info panel shown when the store node is selected.
/// </summary>
public class SecureStoreInfoFormViewModel : ViewModelBase
{
/// <summary>
/// Gets the instruction text to display.
/// </summary>
public string InstructionText => "Select a secret from the tree to view or edit its value.";
/// <summary>
/// Gets the store path for display.
/// </summary>
public string StorePath { get; }
/// <summary>
/// Gets the key file path for display.
/// </summary>
public string KeyFilePath { get; }
/// <summary>
/// Gets the number of secrets in the store.
/// </summary>
public int SecretCount { get; }
public SecureStoreInfoFormViewModel(string storePath, string keyFilePath, int secretCount)
{
StorePath = storePath;
KeyFilePath = keyFilePath;
SecretCount = secretCount;
}
}
```
---
## Task 9: Create SecureStoreInfoFormView
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml` (new file)
**Content:** Simple panel with instruction text and optional store info display.
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml.cs` (new file)
**Content:** Code-behind for the view.
---
## Task 10: Update MainWindowViewModel - Remove lock/unlock commands and state
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
**Remove:**
- `_openStores` dictionary (line 41)
- `_selectedStoreNode` field (line 42)
- `UnlockStoreCommand` and related methods
- `LockStoreCommand` and related methods
- `RaiseSecureStoreCommandsCanExecuteChanged()` method
- `CreateLockedStoreFormViewModel()` method
- `CreateUnlockedStoreFormViewModel()` method
- `FindParentStoreNode()` method (if only used for lock state)
---
## Task 11: Update MainWindowViewModel - Add auto-initialization logic
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
**Add new method `InitializeSecureStoreAsync()`:**
```csharp
/// <summary>
/// Initializes the SecureStore automatically on config load.
/// Creates the store if it doesn't exist and AutoCreateStore is true.
/// Opens the store and ensures all required keys exist.
/// </summary>
private async Task InitializeSecureStoreAsync()
{
if (_appSettings?.SecureStore == null)
return;
if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
return;
var secureStoreConfig = _appSettings.SecureStore;
// Resolve paths relative to config folder
var storePath = Path.IsPathRooted(secureStoreConfig.StorePath)
? secureStoreConfig.StorePath
: Path.Combine(ConfigFolderPath, secureStoreConfig.StorePath);
var keyFilePath = Path.IsPathRooted(secureStoreConfig.KeyFilePath)
? secureStoreConfig.KeyFilePath
: Path.Combine(ConfigFolderPath, secureStoreConfig.KeyFilePath);
try
{
if (!File.Exists(storePath))
{
if (!secureStoreConfig.AutoCreateStore)
{
_logger?.LogWarning("SecureStore not found and AutoCreateStore is false");
return;
}
// Create new store with keyfile
_logger?.LogInformation("Creating SecureStore at {StorePath}", storePath);
_secureStoreManager.CreateStore(storePath, keyFilePath);
// Update appsettings.json with actual paths
secureStoreConfig.StorePath = GetRelativePath(ConfigFolderPath, storePath);
secureStoreConfig.KeyFilePath = GetRelativePath(ConfigFolderPath, keyFilePath);
await SaveAppSettingsAsync();
}
else
{
// Open existing store
if (!File.Exists(keyFilePath))
{
await _dialogService!.ShowErrorAsync(
"SecureStore Error",
$"Key file not found: {keyFilePath}\n\nThe SecureStore cannot be opened without its key file.");
return;
}
_secureStoreManager.OpenStore(storePath, keyFilePath);
}
// Ensure all required keys exist
if (secureStoreConfig.RequiredKeys?.Count > 0)
{
var addedKeys = _secureStoreManager.EnsureRequiredKeys(secureStoreConfig.RequiredKeys);
if (addedKeys.Count > 0)
{
_logger?.LogInformation("Added {Count} missing required keys", addedKeys.Count);
}
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to initialize SecureStore");
await _dialogService!.ShowErrorAsync(
"SecureStore Error",
$"Failed to initialize SecureStore:\n\n{ex.Message}");
}
}
private static string GetRelativePath(string basePath, string fullPath)
{
var baseUri = new Uri(basePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar);
var fullUri = new Uri(fullPath);
return Uri.UnescapeDataString(baseUri.MakeRelativeUri(fullUri).ToString().Replace('/', Path.DirectorySeparatorChar));
}
```
---
## Task 12: Update MainWindowViewModel - Modify BuildTreeNodes
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
**Replace the Secure Stores section in `BuildTreeNodes()` (around lines 464-467):**
**Before:**
```csharp
// Secure Stores folder
var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "🔑", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
LoadConfiguredSecureStore(secureStoresFolder);
TreeNodes.Add(secureStoresFolder);
```
**After:**
```csharp
// Secure Store node (single store, secrets as direct children)
var secureStoreNode = CreateSecureStoreNode();
if (secureStoreNode != null)
{
TreeNodes.Add(secureStoreNode);
}
```
**Add new method `CreateSecureStoreNode()`:**
```csharp
private TreeNodeViewModel? CreateSecureStoreNode()
{
if (_appSettings?.SecureStore == null)
return null;
if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
return null;
var storePath = Path.IsPathRooted(_appSettings.SecureStore.StorePath)
? _appSettings.SecureStore.StorePath
: Path.Combine(ConfigFolderPath, _appSettings.SecureStore.StorePath);
var keyFilePath = Path.IsPathRooted(_appSettings.SecureStore.KeyFilePath)
? _appSettings.SecureStore.KeyFilePath
: Path.Combine(ConfigFolderPath, _appSettings.SecureStore.KeyFilePath);
var storeNode = new TreeNodeViewModel("Secure Store", "🔑", TreeNodeType.SecureStore)
{
StorePath = storePath,
KeyFilePath = keyFilePath,
SectionKey = storePath,
IsExpanded = true
};
// Add secrets as direct children if store is open
if (_secureStoreManager.IsStoreOpen)
{
foreach (var key in _secureStoreManager.GetKeys().OrderBy(k => k))
{
var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret)
{
SecretKey = key
};
storeNode.Children.Add(secretNode);
}
}
return storeNode;
}
```
---
## Task 13: Update MainWindowViewModel - Modify OnSelectedNodeChanged
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
**Update the switch statement in `OnSelectedNodeChanged()` (around lines 530-557):**
**Remove:**
- `case TreeNodeType.SecureStoresFolder:` block
- Lock/unlock logic in `case TreeNodeType.SecureStore:` block
**Replace with:**
```csharp
case TreeNodeType.SecureStore:
SelectedFormViewModel = CreateSecureStoreInfoFormViewModel();
return;
case TreeNodeType.Secret:
SelectedFormViewModel = CreateSecretFormViewModel(_selectedNode);
return;
```
**Add method:**
```csharp
private SecureStoreInfoFormViewModel CreateSecureStoreInfoFormViewModel()
{
var storePath = _appSettings?.SecureStore?.StorePath ?? "Unknown";
var keyFilePath = _appSettings?.SecureStore?.KeyFilePath ?? "Unknown";
var secretCount = _secureStoreManager.IsStoreOpen ? _secureStoreManager.GetKeys().Count : 0;
return new SecureStoreInfoFormViewModel(storePath, keyFilePath, secretCount);
}
```
---
## Task 14: Update MainWindowViewModel - Call InitializeSecureStoreAsync on load
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
**Find the method that loads the config folder (likely `LoadConfigFolderAsync` or similar).**
**Add call to `InitializeSecureStoreAsync()` after loading appsettings.json but before building tree nodes:**
```csharp
// After loading appsettings
_appSettings = await _configFileService.LoadAppSettingsAsync(appSettingsPath, ct);
// Initialize SecureStore (auto-create if needed, open, sync required keys)
await InitializeSecureStoreAsync();
// Then build tree nodes
BuildTreeNodes();
```
---
## Task 15: Remove LoadConfiguredSecureStore method
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs`
**Delete the `LoadConfiguredSecureStore()` method entirely (lines 474-514).**
This is replaced by `CreateSecureStoreNode()` and `InitializeSecureStoreAsync()`.
---
## Task 16: Update DataTemplates for form views
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml` (or wherever DataTemplates are defined)
**Remove:**
- DataTemplate for `SecureStoreLockedFormViewModel`
- DataTemplate for `SecureStoreUnlockedFormViewModel`
**Add:**
- DataTemplate for `SecureStoreInfoFormViewModel`
---
## Task 17: Update tests
**File:** `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs`
**Update or remove tests related to:**
- `SecureStoresFolder` node type
- Lock/unlock commands
- Password-based store operations
**Add tests for:**
- Auto-creation of SecureStore on config load
- Sync of missing required keys
- Flattened tree structure
---
## Task 18: Build and verify
```bash
dotnet build NEW/JdeScoping.slnx
dotnet test NEW/JdeScoping.slnx
```
---
## Execution Order
1. Tasks 1-2: TreeNodeViewModel changes (enum, remove lock properties)
2. Tasks 3-6: SecureStoreManager interface and implementation changes
3. Tasks 7-9: Delete old form files, create new info form
4. Tasks 10-15: MainWindowViewModel changes (largest task)
5. Task 16: Update XAML DataTemplates
6. Task 17: Update tests
7. Task 18: Build and verify
---
## Risk Areas
1. **Missing references**: Removing lock/unlock commands may break menu items or toolbar buttons
2. **View DataTemplates**: Must update form selector to use new `SecureStoreInfoFormViewModel`
3. **Test coverage**: Existing tests may expect lock/unlock behavior
## Verification Checklist
- [ ] App builds without errors
- [ ] All tests pass
- [ ] Opening a config folder without SecureStore creates it automatically
- [ ] Opening a config folder with existing SecureStore opens it
- [ ] Missing required keys are added as blank values
- [ ] Tree shows "Secure Store" with secrets directly underneath
- [ ] Clicking "Secure Store" shows instruction panel
- [ ] Clicking a secret shows the secret editor
- [ ] Error dialog shown when keyfile is missing