refactor: UI file upload components and ephemeral RSA key service

Replace InputFile with RadzenUpload in filter panels for better UX,
switch to ephemeral RSA keys (safe for transport-only encryption),
and add test scripts and documentation files.
This commit is contained in:
Joseph Doherty
2026-01-28 17:22:30 -05:00
parent 5dd17cbab8
commit 04383d672c
32 changed files with 9901 additions and 280 deletions
@@ -11,9 +11,9 @@
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
<RadzenUpload Auto="false" Accept=".xlsx,.xls" ChooseText="Upload Data" Icon="upload"
ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Change="@OnUploadChange" Disabled="@IsUploading" class="rz-upload-inline" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
@@ -32,9 +32,28 @@
</RadzenCard>
@code {
/// <summary>
/// Alias for Items to provide semantic naming for component lots.
/// </summary>
[Parameter]
public List<ComponentLotViewModel> ComponentLots
{
get => Items;
set => Items = value;
}
/// <summary>
/// Callback when the component lots list changes.
/// </summary>
[Parameter]
public EventCallback<List<ComponentLotViewModel>> ComponentLotsChanged
{
get => ItemsChanged;
set => ItemsChanged = value;
}
protected override string PanelTitle => "Filter by Component Lot";
protected override string CountLabel => "# of component lots";
protected override string FileInputId => "componentLotFileInput";
protected override string TemplateFilename => "componentlots_template.xlsx";
protected override string EntityName => "component lots";
protected override string ClearConfirmMessage => "Are you sure you want to clear all component lots?";
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using Radzen;
using Radzen.Blazor;
namespace JdeScoping.Client.Components.FilterPanels;
@@ -56,11 +56,6 @@ public abstract class FileUploadFilterPanelBase<TItem> : ComponentBase where TIt
/// </summary>
protected abstract string CountLabel { get; }
/// <summary>
/// Gets the HTML element ID for the file input.
/// </summary>
protected abstract string FileInputId { get; }
/// <summary>
/// Gets the filename for the downloaded template.
/// </summary>
@@ -101,26 +96,19 @@ public abstract class FileUploadFilterPanelBase<TItem> : ComponentBase where TIt
}
/// <summary>
/// Triggers the file input click.
/// Handles file upload from RadzenUpload component.
/// </summary>
protected async Task TriggerFileInput()
/// <param name="args">The upload change event arguments.</param>
protected async Task OnUploadChange(UploadChangeEventArgs args)
{
await JSRuntime.InvokeVoidAsync("jdeScopingInterop.clickElementById", FileInputId);
}
/// <summary>
/// Handles file selection and upload.
/// </summary>
/// <param name="e">The file change event arguments.</param>
protected async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
if (args.Files == null || !args.Files.Any()) return;
var file = args.Files.First();
IsUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var uploadedItems = await UploadFileApiAsync(stream, e.File.Name);
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var uploadedItems = await UploadFileApiAsync(stream, file.Name);
if (uploadedItems != null)
{
@@ -1,6 +1,7 @@
@* Item number filter panel with autocomplete and grid *@
@using JdeScoping.Core.ApiContracts
@using Microsoft.JSInterop
@using Radzen.Blazor
@inject ILookupApiClient LookupApi
@inject IFileApiClient FileApi
@inject DialogService DialogService
@@ -13,9 +14,9 @@
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="itemNumberFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenUpload Auto="false" Accept=".xlsx,.xls" ChooseText="Upload Data" Icon="upload"
ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Change="@OnUploadChange" Disabled="@_isUploading" class="rz-upload-inline" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
@@ -152,20 +153,16 @@
);
}
private async Task TriggerFileInput()
private async Task OnUploadChange(UploadChangeEventArgs args)
{
await JSRuntime.InvokeVoidAsync("jdeScopingInterop.clickElementById", "itemNumberFileInput");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
if (args.Files == null || !args.Files.Any()) return;
var file = args.Files.First();
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
var result = await FileApi.UploadItemsAsync(stream, e.File.Name);
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
var result = await FileApi.UploadItemsAsync(stream, file.Name);
result.Switch(
items =>
@@ -10,9 +10,9 @@
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
<RadzenUpload Auto="false" Accept=".xlsx,.xls" ChooseText="Upload Data" Icon="upload"
ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Change="@OnUploadChange" Disabled="@IsUploading" class="rz-upload-inline" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
@@ -33,9 +33,28 @@
</RadzenCard>
@code {
/// <summary>
/// Alias for Items to provide semantic naming for part operations.
/// </summary>
[Parameter]
public List<PartOperationViewModel> PartOperations
{
get => Items;
set => Items = value;
}
/// <summary>
/// Callback when the part operations list changes.
/// </summary>
[Parameter]
public EventCallback<List<PartOperationViewModel>> PartOperationsChanged
{
get => ItemsChanged;
set => ItemsChanged = value;
}
protected override string PanelTitle => "Filter By Item/Operation/MIS";
protected override string CountLabel => "# of item / operations";
protected override string FileInputId => "partOperationFileInput";
protected override string TemplateFilename => "partoperations_template.xlsx";
protected override string EntityName => "part operations";
protected override string ClearConfirmMessage => "Are you sure you want to clear all item/operation/MIS entries?";
@@ -10,9 +10,9 @@
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
<RadzenUpload Auto="false" Accept=".xlsx,.xls" ChooseText="Upload Data" Icon="upload"
ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Change="@OnUploadChange" Disabled="@IsUploading" class="rz-upload-inline" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
@@ -31,9 +31,28 @@
</RadzenCard>
@code {
/// <summary>
/// Alias for Items to provide semantic naming for work orders.
/// </summary>
[Parameter]
public List<WorkOrderViewModel> WorkOrders
{
get => Items;
set => Items = value;
}
/// <summary>
/// Callback when the work orders list changes.
/// </summary>
[Parameter]
public EventCallback<List<WorkOrderViewModel>> WorkOrdersChanged
{
get => ItemsChanged;
set => ItemsChanged = value;
}
protected override string PanelTitle => "Filter by Work Order";
protected override string CountLabel => "# of work orders";
protected override string FileInputId => "workOrderFileInput";
protected override string TemplateFilename => "workorders_template.xlsx";
protected override string EntityName => "work orders";
protected override string ClearConfirmMessage => "Are you sure you want to clear all work orders?";
@@ -284,4 +284,14 @@ code {
/* Container fluid */
.container-fluid {
padding: 0;
}
/* RadzenUpload inline button style (no drop zone) */
.rz-upload-inline .rz-fileupload-buttonbar {
padding: 0;
border: none;
background: transparent;
}
.rz-upload-inline .rz-fileupload-content {
display: none;
}
-1
View File
@@ -52,7 +52,6 @@
"KeyFilePath": "data/secrets.key",
"AutoCreateStore": true,
"RequiredKeys": [
"RsaPrivateKey",
"ExcelExport:CriteriaSheetPassword",
"ExcelExport:DataSheetPassword"
]
@@ -54,11 +54,10 @@ public static class InfrastructureDependencyInjection
services.AddSingleton<ISecureStoreService, SecureStoreService>();
// Register RSA key service backed by SecureStore
services.Configure<RsaKeyOptions>(
configuration.GetSection(RsaKeyOptions.SectionName));
services.AddSingleton<IRsaKeyService, SecureStoreRsaKeyService>();
// Register RSA key service - ephemeral keys generated at startup
// This is safe because RSA keys are only used for transport encryption of
// login credentials, and clients fetch the public key fresh before each login.
services.AddSingleton<IRsaKeyService, EphemeralRsaKeyService>();
// Register configuration validators
services.AddInfrastructureValidators(configuration);
@@ -0,0 +1,59 @@
using System.Security.Cryptography;
using JdeScoping.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Infrastructure.Security;
/// <summary>
/// RSA key service that generates a new key pair at startup.
/// No persistence required - keys are ephemeral and regenerated each launch.
/// </summary>
/// <remarks>
/// This is safe because:
/// 1. RSA keys are only used for transport encryption of login credentials
/// 2. Clients fetch the public key fresh before each login attempt
/// 3. No persistent data is encrypted with these keys
/// </remarks>
public class EphemeralRsaKeyService : IRsaKeyService, IDisposable
{
private readonly RSA _rsa;
private readonly ILogger<EphemeralRsaKeyService> _logger;
private bool _disposed;
/// <summary>
/// Creates a new ephemeral RSA key service with a freshly generated key pair.
/// </summary>
/// <param name="logger">Logger for key service operations.</param>
public EphemeralRsaKeyService(ILogger<EphemeralRsaKeyService> logger)
{
_logger = logger;
_rsa = RSA.Create(2048);
_logger.LogInformation("Ephemeral RSA key pair generated for this application instance");
}
/// <inheritdoc />
public string GetPublicKeyPem()
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _rsa.ExportSubjectPublicKeyInfoPem();
}
/// <inheritdoc />
public byte[] Decrypt(byte[] ciphertext)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
}
/// <summary>
/// Releases resources used by the RSA key service.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_rsa.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
@@ -1,77 +0,0 @@
using System.Security.Cryptography;
using JdeScoping.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Infrastructure.Security;
/// <summary>
/// RSA key service that stores keys in SecureStore instead of plain files.
/// </summary>
public class SecureStoreRsaKeyService : IRsaKeyService, IDisposable
{
/// <summary>
/// Key name used in SecureStore for the RSA private key.
/// </summary>
public const string RsaPrivateKeyName = "RsaPrivateKey";
private readonly RSA _rsa;
private readonly ISecureStoreService _secureStore;
private readonly ILogger<SecureStoreRsaKeyService> _logger;
private bool _disposed;
/// <summary>
/// Creates a new SecureStore-backed RSA key service.
/// </summary>
/// <param name="secureStore">Service for storing keys securely.</param>
/// <param name="logger">Logger for key service operations.</param>
public SecureStoreRsaKeyService(
ISecureStoreService secureStore,
ILogger<SecureStoreRsaKeyService> logger)
{
_secureStore = secureStore;
_logger = logger;
_rsa = RSA.Create(2048);
if (_secureStore.Contains(RsaPrivateKeyName))
{
// Load existing key from SecureStore
var pemKey = _secureStore.GetRequired(RsaPrivateKeyName);
_rsa.ImportFromPem(pemKey);
_logger.LogInformation("RSA key loaded from secure store");
}
else
{
// Generate new key and store in SecureStore
var pemKey = _rsa.ExportRSAPrivateKeyPem();
_secureStore.Set(RsaPrivateKeyName, pemKey);
_secureStore.Save();
_logger.LogInformation("New RSA key generated and stored in secure store");
}
}
/// <inheritdoc />
public string GetPublicKeyPem()
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _rsa.ExportSubjectPublicKeyInfoPem();
}
/// <inheritdoc />
public byte[] Decrypt(byte[] ciphertext)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
}
/// <summary>
/// Releases resources used by the RSA key service.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_rsa.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,101 @@
using System.Security.Cryptography;
using System.Text;
using JdeScoping.Infrastructure.Security;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace JdeScoping.Infrastructure.Tests.Security;
public class EphemeralRsaKeyServiceTests : IDisposable
{
private readonly ILogger<EphemeralRsaKeyService> _logger;
private readonly EphemeralRsaKeyService _service;
public EphemeralRsaKeyServiceTests()
{
_logger = Substitute.For<ILogger<EphemeralRsaKeyService>>();
_service = new EphemeralRsaKeyService(_logger);
}
[Fact]
public void Constructor_GeneratesNewKeyPair()
{
// Assert - service was created with a key that works
var publicKey = _service.GetPublicKeyPem();
publicKey.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
publicKey.ShouldEndWith("-----END PUBLIC KEY-----");
}
[Fact]
public void GetPublicKeyPem_ReturnsValidPemFormat()
{
// Act
var publicKey = _service.GetPublicKeyPem();
// Assert - key can be imported
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKey);
rsa.KeySize.ShouldBe(2048);
}
[Fact]
public void Decrypt_WithValidCiphertext_ReturnsPlaintext()
{
// Arrange
var plaintext = "Hello, World!"u8.ToArray();
var publicKeyPem = _service.GetPublicKeyPem();
// Encrypt with public key
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
var ciphertext = rsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
// Act
var decrypted = _service.Decrypt(ciphertext);
// Assert
Encoding.UTF8.GetString(decrypted).ShouldBe("Hello, World!");
}
[Fact]
public void Decrypt_WithInvalidCiphertext_ThrowsCryptographicException()
{
// Arrange
var invalidCiphertext = new byte[256]; // Random bytes won't decrypt
// Act & Assert
Should.Throw<CryptographicException>(() => _service.Decrypt(invalidCiphertext));
}
[Fact]
public void MultipleInstances_HaveDifferentKeys()
{
// Arrange
using var service2 = new EphemeralRsaKeyService(_logger);
// Act
var key1 = _service.GetPublicKeyPem();
var key2 = service2.GetPublicKeyPem();
// Assert - each instance has its own unique key
key1.ShouldNotBe(key2);
}
[Fact]
public void Dispose_PreventsSubsequentOperations()
{
// Arrange
var service = new EphemeralRsaKeyService(_logger);
service.Dispose();
// Act & Assert
Should.Throw<ObjectDisposedException>(() => service.GetPublicKeyPem());
Should.Throw<ObjectDisposedException>(() => service.Decrypt(new byte[1]));
}
public void Dispose()
{
_service.Dispose();
}
}
@@ -1,153 +0,0 @@
using JdeScoping.Infrastructure.Security;
using JdeScoping.Infrastructure.Tests.Helpers;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using System.Security.Cryptography;
namespace JdeScoping.Infrastructure.Tests.Security;
public class SecureStoreRsaKeyServiceTests : IDisposable
{
private readonly InMemorySecureStore _secureStore;
public SecureStoreRsaKeyServiceTests()
{
_secureStore = new InMemorySecureStore();
}
public void Dispose()
{
_secureStore.Dispose();
}
private SecureStoreRsaKeyService CreateService()
{
return new SecureStoreRsaKeyService(
_secureStore,
NullLogger<SecureStoreRsaKeyService>.Instance);
}
[Fact]
public void Constructor_WhenNoKeyInStore_GeneratesNewKeyAndStoresIt()
{
// Arrange - ensure no key exists
_secureStore.Contains(SecureStoreRsaKeyService.RsaPrivateKeyName).ShouldBeFalse();
// Act
using var service = CreateService();
// Assert - key should now be stored
_secureStore.Contains(SecureStoreRsaKeyService.RsaPrivateKeyName).ShouldBeTrue();
var storedKey = _secureStore.Get(SecureStoreRsaKeyService.RsaPrivateKeyName);
storedKey.ShouldStartWith("-----BEGIN RSA PRIVATE KEY-----");
}
[Fact]
public void Constructor_WhenKeyExistsInStore_LoadsExistingKey()
{
// Arrange - pre-store a key
using var originalRsa = RSA.Create(2048);
var originalPem = originalRsa.ExportRSAPrivateKeyPem();
var originalPublicKey = originalRsa.ExportSubjectPublicKeyInfoPem();
_secureStore.Set(SecureStoreRsaKeyService.RsaPrivateKeyName, originalPem);
// Act
using var service = CreateService();
// Assert - should load the same key
var loadedPublicKey = service.GetPublicKeyPem();
loadedPublicKey.ShouldBe(originalPublicKey);
}
[Fact]
public void GetPublicKeyPem_ReturnsValidPemFormat()
{
// Arrange
using var service = CreateService();
// Act
var pem = service.GetPublicKeyPem();
// Assert
pem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
pem.ShouldEndWith("-----END PUBLIC KEY-----");
}
[Fact]
public void Decrypt_WithValidCiphertext_ReturnsPlaintext()
{
// Arrange
using var service = CreateService();
var plaintext = "Hello, World!"u8.ToArray();
// Encrypt using public key (simulating what client does)
using var rsa = RSA.Create();
rsa.ImportFromPem(service.GetPublicKeyPem());
var ciphertext = rsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
// Act
var decrypted = service.Decrypt(ciphertext);
// Assert
decrypted.ShouldBe(plaintext);
}
[Fact]
public void Decrypt_WithInvalidCiphertext_ThrowsCryptographicException()
{
// Arrange
using var service = CreateService();
var invalidCiphertext = new byte[] { 1, 2, 3, 4, 5 };
// Act & Assert
Should.Throw<CryptographicException>(() => service.Decrypt(invalidCiphertext));
}
[Fact]
public void MultipleInstances_WithSameStore_UseSameKey()
{
// Arrange & Act
using var service1 = CreateService();
var publicKey1 = service1.GetPublicKeyPem();
// Create second instance with same store
using var service2 = new SecureStoreRsaKeyService(
_secureStore,
NullLogger<SecureStoreRsaKeyService>.Instance);
var publicKey2 = service2.GetPublicKeyPem();
// Assert
publicKey2.ShouldBe(publicKey1);
}
[Fact]
public void EncryptDecrypt_RoundTrip_Succeeds()
{
// Arrange
using var service = CreateService();
var originalMessage = "Sensitive password data 123!"u8.ToArray();
// Act - encrypt with public key
using var clientRsa = RSA.Create();
clientRsa.ImportFromPem(service.GetPublicKeyPem());
var encrypted = clientRsa.Encrypt(originalMessage, RSAEncryptionPadding.OaepSHA256);
// Act - decrypt with service (which has private key)
var decrypted = service.Decrypt(encrypted);
// Assert
decrypted.ShouldBe(originalMessage);
}
[Fact]
public void Operations_AfterDispose_ThrowObjectDisposedException()
{
// Arrange
var service = CreateService();
service.Dispose();
// Act & Assert
Should.Throw<ObjectDisposedException>(() => service.GetPublicKeyPem());
Should.Throw<ObjectDisposedException>(() => service.Decrypt(Array.Empty<byte>()));
}
}