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