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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user