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:
@@ -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)
|
||||
{
|
||||
|
||||
+18
-54
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
-52
@@ -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; }
|
||||
}
|
||||
-92
@@ -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
-2
@@ -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>
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Views.Forms;
|
||||
|
||||
public partial class SecureStoreUnlockedFormView : UserControl
|
||||
{
|
||||
public SecureStoreUnlockedFormView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
-41
@@ -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()
|
||||
{
|
||||
|
||||
+42
-11
@@ -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();
|
||||
|
||||
-115
@@ -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();
|
||||
}
|
||||
}
|
||||
-327
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user