From 0dbc0c02f93b701bc900e8fdeb9fe6367d4c2ef1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 05:30:16 -0400 Subject: [PATCH] feat(centralui): TransportExport wizard under Design nav group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task T21 of the Transport feature. A four-step Blazor wizard (Select → Review → Encrypt → Download) under /design/transport/export, gated on AuthorizationPolicies.RequireDesign: 1. Select — TemplateFolderTree (checkbox-mode) plus flat checkbox lists for shared scripts, external systems, DB connections, notification lists, SMTP configs, API keys, API methods. 2. Review — runs DependencyResolver, surfaces seed vs auto-included. "Include all dependencies" toggle re-resolves on flip. 3. Encrypt — passphrase + confirm with strength meter, secret-count warning over the resolved closure, explicit unencrypted opt-out path (calls BundleExporter with passphrase=null so the audit row tags UnencryptedBundleExport). 4. Download— calls IBundleExporter.ExportAsync, streams bytes to the browser via JS interop (wwwroot/js/transport.js), displays filename + size + SHA-256 + encryption status. Source environment is sourced from new TransportOptions.SourceEnvironment (bound from ScadaLink:Transport:SourceEnvironment, defaults "scadalink"), filename pattern scadabundle-{env}-{yyyy-MM-dd-HHmmss}.scadabundle. Tests (bUnit + policy): step 1 group rendering, step 2 dependency expansion (Pump composes Motor), step 4 full walkthrough verifying ExportAsync receives the selected ids + authenticated identity, and a RequireDesign policy-deny test for users without the Design role. Also unit-pins the filename-sanitisation contract. --- .../Pages/Design/TransportExport.razor | 463 ++++++++++++++++++ .../Pages/Design/TransportExport.razor.cs | 427 ++++++++++++++++ .../ScadaLink.CentralUI.csproj | 1 + .../wwwroot/js/transport.js | 20 + src/ScadaLink.Host/Components/App.razor | 1 + src/ScadaLink.Transport/TransportOptions.cs | 9 + .../Pages/Design/TransportExportPageTests.cs | 325 ++++++++++++ 7 files changed, 1246 insertions(+) create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs create mode 100644 src/ScadaLink.CentralUI/wwwroot/js/transport.js create mode 100644 tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor new file mode 100644 index 0000000..5995542 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor @@ -0,0 +1,463 @@ +@page "/design/transport/export" +@using ScadaLink.Security +@using ScadaLink.Commons.Entities.Templates +@using ScadaLink.Commons.Entities.Scripts +@using ScadaLink.Commons.Entities.ExternalSystems +@using ScadaLink.Commons.Entities.Notifications +@using ScadaLink.Commons.Entities.InboundApi +@using ScadaLink.CentralUI.Components.Shared +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] + +@* + TransportExport wizard (Component #24, Task T21). + + A 4-step linear wizard: + Step 1 — Select : templates (tree, checkbox-mode) + flat artifact lists. + Step 2 — Review : resolved closure split into seed vs auto-included. + Step 3 — Encrypt : passphrase + confirm, or explicit unencrypted opt-out. + Step 4 — Download : streams the bundle bytes to the browser via JS interop. + + The page is Design-role gated; deeper interactions (audit row, secrets + warning) come from BundleExporter itself. +*@ + +
+ @if (_loading) + { + + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { +

Export Bundle

+ + @* Step indicator — Bootstrap progress with discrete numbered pills. *@ + + + @switch (_step) + { + case ExportWizardStep.Select: + @RenderStepSelect(); + break; + case ExportWizardStep.Review: + @RenderStepReview(); + break; + case ExportWizardStep.Encrypt: + @RenderStepEncrypt(); + break; + case ExportWizardStep.Download: + @RenderStepDownload(); + break; + } + } +
+ +@code { + private string StepClass(ExportWizardStep s) => + s == _step ? "fw-semibold text-primary" + : (int)s < (int)_step ? "text-success" + : "text-muted"; + + // ============================================================ + // Step 1 — Select + // ============================================================ + private RenderFragment RenderStepSelect() => __builder => + { +
+
+ + +
+ +
+ Templates + @if (_templates.Count == 0) + { +
No templates.
+ } + else + { +
+ +
+ } +
+ +
+ Shared Scripts + @RenderCheckboxList(_sharedScripts, s => s.Id, s => s.Name, _selectedSharedScripts) +
+ +
+ External Systems + @RenderCheckboxList(_externalSystems, e => e.Id, e => e.Name, _selectedExternalSystems) +
+ +
+ Database Connections + @RenderCheckboxList(_dbConnections, d => d.Id, d => d.Name, _selectedDbConnections) +
+ +
+ Notification Lists + @RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists) +
+ +
+ SMTP Configurations + @RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs) +
+ +
+ API Keys + @RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys) +
+ +
+ API Methods + @RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods) +
+ +
+ +
+
+ }; + + private void OnTemplateSelectionChanged(HashSet keys) + { + // TemplateFolderTree hands back a fresh HashSet each time; mirror it + // into our owned set so subsequent renders see the same instance the + // tree is binding against. + _selectedTemplateKeys.Clear(); + foreach (var k in keys) + { + _selectedTemplateKeys.Add(k); + } + } + + private RenderFragment RenderCheckboxList( + IReadOnlyList items, + Func idOf, + Func nameOf, + HashSet selected) => __builder => + { + var visible = items.Where(x => MatchesFilter(nameOf(x))).ToList(); + if (visible.Count == 0) + { +
No matches.
+ return; + } + +
+ @foreach (var item in visible) + { + var id = idOf(item); + var inputId = $"chk-{typeof(T).Name}-{id}"; +
+ + +
+ } +
+ }; + + // ============================================================ + // Step 2 — Review + // ============================================================ + private RenderFragment RenderStepReview() => __builder => + { + if (_resolved is null) + { +
Nothing resolved yet — please go back to step 1.
+ return; + } + + var seedTemplateIds = new HashSet(SelectedTemplateIds()); + var seedSharedScripts = new HashSet(_selectedSharedScripts); + var seedExternalSystems = new HashSet(_selectedExternalSystems); + + var autoTemplates = AutoIncluded(_resolved.Templates, seedTemplateIds, t => t.Id); + var autoShared = AutoIncluded(_resolved.SharedScripts, seedSharedScripts, s => s.Id); + var autoExternals = AutoIncluded(_resolved.ExternalSystems, seedExternalSystems, e => e.Id); + +
+

+ The resolver walked your selection's dependency graph and produced the closure + below. Items under Auto-included were pulled in because the items you + ticked reference them; unticking + Include all dependencies exports the seed alone. +

+ +
+ + +
+ +
+
+
Selected by you
+
    + @foreach (var t in _resolved.Templates.Where(t => seedTemplateIds.Contains(t.Id)).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • Template: @t.Name
  • + } + @foreach (var s in _resolved.SharedScripts.Where(s => seedSharedScripts.Contains(s.Id)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • SharedScript: @s.Name
  • + } + @foreach (var e in _resolved.ExternalSystems.Where(e => seedExternalSystems.Contains(e.Id)).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • ExternalSystem: @e.Name
  • + } + @foreach (var d in _resolved.DatabaseConnections.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • DatabaseConnection: @d.Name
  • + } + @foreach (var n in _resolved.NotificationLists.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • NotificationList: @n.Name
  • + } + @foreach (var s in _resolved.SmtpConfigs.OrderBy(s => s.Host, StringComparer.OrdinalIgnoreCase)) + { +
  • SmtpConfig: @s.Host
  • + } + @foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • ApiKey: @k.Name
  • + } + @foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • ApiMethod: @m.Name
  • + } +
+
+
+
Auto-included (dependencies)
+ @if (autoTemplates.Count + autoShared.Count + autoExternals.Count + _resolved.TemplateFolders.Count == 0) + { +
No additional dependencies.
+ } + else + { +
    + @foreach (var f in _resolved.TemplateFolders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • TemplateFolder: @f.Name
  • + } + @foreach (var t in autoTemplates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • Template: @t.Name
  • + } + @foreach (var s in autoShared.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • SharedScript: @s.Name
  • + } + @foreach (var e in autoExternals.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • ExternalSystem: @e.Name
  • + } +
+ } +
+
+ +
+ + +
+
+ }; + + // ============================================================ + // Step 3 — Encrypt + // ============================================================ + private RenderFragment RenderStepEncrypt() => __builder => + { + var strength = PassphraseStrength(_passphrase); + var strengthLabel = strength switch + { + 0 => "Too short", + 1 => "Weak", + 2 => "Fair", + 3 => "Good", + _ => "Strong", + }; + var strengthColor = strength switch + { + <= 1 => "bg-danger", + 2 => "bg-warning", + 3 => "bg-info", + _ => "bg-success", + }; + +
+ @if (_secretCount > 0) + { + + } + else + { + + } + +
+ + +
+
+
+
Strength: @strengthLabel · minimum 8 characters.
+
+ +
+ + + @if (!string.IsNullOrEmpty(_passphraseConfirm) && _passphrase != _passphraseConfirm) + { +
Passphrases do not match.
+ } +
+ +

+ + Export without encryption… + +

+ + @if (_showUnencryptedConfirm) + { +
+ Unencrypted export — the bundle will contain all secret fields + in plaintext. Anyone with the file can read external-system credentials, SMTP + passwords, and database connection strings. The audit log will record this as + UnencryptedBundleExport. +
+ + +
+
+ } + +
+ + +
+
+ }; + + // ============================================================ + // Step 4 — Download + // ============================================================ + private RenderFragment RenderStepDownload() => __builder => + { +
+ @if (_downloadInProgress) + { + +

Building bundle…

+ } + else if (_downloadError != null) + { +
+ Export failed: @_downloadError +
+ + } + else + { +
+ Bundle ready. Your browser is downloading the file. +
+ +
+
Filename
+
@_downloadFilename
+ +
Size
+
@FormatBytes(_downloadSize)
+ +
SHA-256
+
@_downloadSha256
+ +
Encryption
+
+ @if (_exportUnencrypted) + { + Unencrypted (audited as UnencryptedBundleExport) + } + else + { + AES-256-GCM with PBKDF2-SHA256 + } +
+
+ + + } +
+ }; + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:0.0} KB"; + return $"{bytes / (1024.0 * 1024.0):0.00} MB"; + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs new file mode 100644 index 0000000..c968e47 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs @@ -0,0 +1,427 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; +using ScadaLink.CentralUI.Auth; +using ScadaLink.Commons.Entities.ExternalSystems; +using ScadaLink.Commons.Entities.InboundApi; +using ScadaLink.Commons.Entities.Notifications; +using ScadaLink.Commons.Entities.Scripts; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Transport; +using ScadaLink.Commons.Types.Transport; +using ScadaLink.Transport; +using ScadaLink.Transport.Export; + +namespace ScadaLink.CentralUI.Components.Pages.Design; + +/// +/// Code-behind for the TransportExport wizard (Transport feature, Task T21). +/// +/// Four-step state machine: +/// +/// — pick templates + flat artifact lists. +/// — show resolved closure (seed + auto-included). +/// — passphrase + secret-count warning, or explicit unencrypted opt-out. +/// — call , render the file via JS interop. +/// +/// +/// The wizard transitions are linear; "Back" returns to the previous step +/// without clearing selection state. "Done" on Step 4 resets to Step 1 fresh. +/// +/// SourceEnvironment is sourced from +/// (bound from ScadaLink:Transport:SourceEnvironment) so a multi-cluster +/// deployment can label its bundles distinctly. Defaults to "scadalink". +/// +public partial class TransportExport : ComponentBase +{ + public enum ExportWizardStep + { + Select = 1, + Review = 2, + Encrypt = 3, + Download = 4, + } + + // ---- Injected services ---- + [Inject] private IBundleExporter BundleExporter { get; set; } = default!; + [Inject] private ITemplateEngineRepository TemplateRepo { get; set; } = default!; + [Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!; + [Inject] private INotificationRepository NotificationRepo { get; set; } = default!; + [Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!; + [Inject] private DependencyResolver DepResolver { get; set; } = default!; + [Inject] private IJSRuntime JS { get; set; } = default!; + [Inject] private AuthenticationStateProvider Auth { get; set; } = default!; + [Inject] private IOptions TransportOptions { get; set; } = default!; + + // ---- Wizard state ---- + private ExportWizardStep _step = ExportWizardStep.Select; + private bool _loading = true; + private string? _errorMessage; + + // ---- Step 1: source data ---- + private List