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
@@ -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);