9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
593 lines
28 KiB
Plaintext
593 lines
28 KiB
Plaintext
@page "/design/transport/export"
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
|
|
|
@*
|
|
TransportExport wizard (Component #24).
|
|
|
|
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.
|
|
*@
|
|
|
|
<div class="container-fluid mt-3">
|
|
@if (_loading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else if (_errorMessage != null)
|
|
{
|
|
<div class="alert alert-danger">@_errorMessage</div>
|
|
}
|
|
else
|
|
{
|
|
<h4 class="mb-3">Export Bundle</h4>
|
|
|
|
@* Step indicator — Bootstrap progress with discrete numbered pills. *@
|
|
<nav aria-label="Export wizard steps" class="mb-4">
|
|
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
|
|
<li class="@StepClass(ExportWizardStep.Select)">
|
|
<span class="badge rounded-pill me-1">1</span> Select
|
|
</li>
|
|
<li class="@StepClass(ExportWizardStep.Review)">
|
|
<span class="badge rounded-pill me-1">2</span> Review
|
|
</li>
|
|
<li class="@StepClass(ExportWizardStep.Encrypt)">
|
|
<span class="badge rounded-pill me-1">3</span> Encrypt
|
|
</li>
|
|
<li class="@StepClass(ExportWizardStep.Download)">
|
|
<span class="badge rounded-pill me-1">4</span> Download
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
@switch (_step)
|
|
{
|
|
case ExportWizardStep.Select:
|
|
@RenderStepSelect();
|
|
break;
|
|
case ExportWizardStep.Review:
|
|
@RenderStepReview();
|
|
break;
|
|
case ExportWizardStep.Encrypt:
|
|
@RenderStepEncrypt();
|
|
break;
|
|
case ExportWizardStep.Download:
|
|
@RenderStepDownload();
|
|
break;
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@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 =>
|
|
{
|
|
<div>
|
|
<div class="mb-3">
|
|
<label for="export-filter" class="form-label">Search</label>
|
|
<input id="export-filter" type="search" class="form-control"
|
|
placeholder="Filter all artifacts…"
|
|
@bind="_filter" @bind:event="oninput" />
|
|
</div>
|
|
|
|
<fieldset class="mb-4" data-testid="group-templates">
|
|
<legend class="h6">Templates</legend>
|
|
@if (_templates.Count == 0)
|
|
{
|
|
<div class="text-muted small fst-italic">No templates.</div>
|
|
}
|
|
else
|
|
{
|
|
<div style="max-height: 320px; overflow-y: auto; padding: 4px; border: 1px solid var(--bs-border-color); border-radius: 4px;">
|
|
<TemplateFolderTree Folders="_folders"
|
|
Templates="_templates"
|
|
SelectionMode="TreeViewSelectionMode.Checkbox"
|
|
SelectedKeys="_selectedTemplateKeys"
|
|
SelectedKeysChanged="OnTemplateSelectionChanged"
|
|
Filter="@_filter" />
|
|
</div>
|
|
}
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4" data-testid="group-shared-scripts">
|
|
<legend class="h6">Shared Scripts</legend>
|
|
@RenderCheckboxList(_sharedScripts, s => s.Id, s => s.Name, _selectedSharedScripts)
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4" data-testid="group-external-systems">
|
|
<legend class="h6">External Systems</legend>
|
|
@RenderCheckboxList(_externalSystems, e => e.Id, e => e.Name, _selectedExternalSystems)
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4" data-testid="group-db-connections">
|
|
<legend class="h6">Database Connections</legend>
|
|
@RenderCheckboxList(_dbConnections, d => d.Id, d => d.Name, _selectedDbConnections)
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4" data-testid="group-notification-lists">
|
|
<legend class="h6">Notification Lists</legend>
|
|
@RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists)
|
|
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="smtp-hint">
|
|
Selecting a notification list does <strong>not</strong> automatically include its
|
|
SMTP configuration. SMTP configurations are environment-specific and must be
|
|
selected separately if you want them in the bundle.
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4" data-testid="group-smtp-configs">
|
|
<legend class="h6">SMTP Configurations</legend>
|
|
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
|
|
</fieldset>
|
|
|
|
@* SMS provider configs, mirroring the SMTP section above. Labelled by
|
|
AccountSid (the bundle key); the secret AuthToken is never rendered. *@
|
|
<fieldset class="mb-4" data-testid="group-sms-configs">
|
|
<legend class="h6">SMS Configurations</legend>
|
|
@RenderCheckboxList(_smsConfigs, s => s.Id, s => s.AccountSid, _selectedSmsConfigs)
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4" data-testid="group-api-methods">
|
|
<legend class="h6">API Methods</legend>
|
|
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
|
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="api-keys-not-transported">
|
|
<strong>API keys are not part of config transport.</strong> Inbound API keys
|
|
live in each environment's own secret store and cannot be exported. After
|
|
importing, re-create the keys on the destination and re-grant their method
|
|
scopes via the admin UI/CLI.
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4" data-testid="group-sites">
|
|
<legend class="h6">Sites & Instances</legend>
|
|
@RenderSitesList()
|
|
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="sites-hint">
|
|
Selecting a <strong>site</strong> includes its data connections and all of its
|
|
instances. Expand a site to pick individual <strong>instances</strong> instead.
|
|
Each instance pulls in its template (and that template's dependency closure)
|
|
when <em>Include all dependencies</em> is on.
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div class="d-flex justify-content-end gap-2 mt-4">
|
|
<button class="btn btn-primary"
|
|
disabled="@(!HasAnySelection || _resolving)"
|
|
@onclick="GoToReviewAsync">
|
|
@(_resolving ? "Resolving…" : "Next")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
private void OnTemplateSelectionChanged(HashSet<object> 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<T>(
|
|
IReadOnlyList<T> items,
|
|
Func<T, int> idOf,
|
|
Func<T, string> nameOf,
|
|
HashSet<int> selected) => __builder =>
|
|
{
|
|
var visible = items.Where(x => MatchesFilter(nameOf(x))).ToList();
|
|
if (visible.Count == 0)
|
|
{
|
|
<div class="text-muted small fst-italic">No matches.</div>
|
|
return;
|
|
}
|
|
|
|
<div class="d-flex flex-column gap-1">
|
|
@foreach (var item in visible)
|
|
{
|
|
var id = idOf(item);
|
|
var inputId = $"chk-{typeof(T).Name}-{id}";
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox"
|
|
id="@inputId"
|
|
checked="@selected.Contains(id)"
|
|
@onchange="e => Toggle(selected, id, ((bool?)e.Value) == true)" />
|
|
<label class="form-check-label" for="@inputId">@nameOf(item)</label>
|
|
</div>
|
|
}
|
|
</div>
|
|
};
|
|
|
|
// Sites & Instances picker (M8 E1): a flat site checkbox list, each row
|
|
// expandable to reveal its instances. The search box matches a site by its
|
|
// Name or SiteIdentifier, and an instance by its UniqueName; a site stays
|
|
// visible when any of its instances match so the operator can drill in.
|
|
private RenderFragment RenderSitesList() => __builder =>
|
|
{
|
|
var visibleSites = _sites
|
|
.Where(site => MatchesFilter(site.Name)
|
|
|| MatchesFilter(site.SiteIdentifier)
|
|
|| InstancesFor(site.Id).Any(i => MatchesFilter(i.UniqueName)))
|
|
.ToList();
|
|
|
|
if (visibleSites.Count == 0)
|
|
{
|
|
<div class="text-muted small fst-italic">@(_sites.Count == 0 ? "No sites." : "No matches.")</div>
|
|
return;
|
|
}
|
|
|
|
<div class="d-flex flex-column gap-1">
|
|
@foreach (var site in visibleSites)
|
|
{
|
|
var siteInputId = $"chk-site-{site.Id}";
|
|
var expanded = _expandedSites.Contains(site.Id);
|
|
var instances = InstancesFor(site.Id);
|
|
<div class="border rounded p-2" data-testid="site-row" data-site-id="@site.Id">
|
|
<div class="d-flex align-items-center gap-2">
|
|
@if (instances.Count > 0)
|
|
{
|
|
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none"
|
|
aria-expanded="@expanded.ToString().ToLowerInvariant()"
|
|
aria-label="@(expanded ? "Collapse" : "Expand") instances for @site.Name"
|
|
@onclick="() => ToggleSiteExpanded(site.Id)">
|
|
<span aria-hidden="true">@(expanded ? "▾" : "▸")</span>
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted" style="width: 1rem; display: inline-block;" aria-hidden="true"></span>
|
|
}
|
|
<div class="form-check mb-0">
|
|
<input class="form-check-input" type="checkbox"
|
|
id="@siteInputId"
|
|
checked="@_selectedSites.Contains(site.Id)"
|
|
@onchange="e => ToggleSite(site.Id, ((bool?)e.Value) == true)" />
|
|
<label class="form-check-label" for="@siteInputId">
|
|
@site.Name <span class="text-muted small">(@site.SiteIdentifier)</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
@if (expanded && instances.Count > 0)
|
|
{
|
|
<div class="ms-4 mt-2 d-flex flex-column gap-1" data-testid="site-instances">
|
|
@foreach (var instance in instances.Where(i => MatchesFilter(i.UniqueName)
|
|
|| MatchesFilter(site.Name) || MatchesFilter(site.SiteIdentifier)))
|
|
{
|
|
var instInputId = $"chk-instance-{instance.Id}";
|
|
var siteSelected = _selectedSites.Contains(site.Id);
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox"
|
|
id="@instInputId"
|
|
disabled="@siteSelected"
|
|
checked="@(siteSelected || _selectedInstances.Contains(instance.Id))"
|
|
@onchange="e => Toggle(_selectedInstances, instance.Id, ((bool?)e.Value) == true)" />
|
|
<label class="form-check-label" for="@instInputId">@instance.UniqueName</label>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
};
|
|
|
|
// ============================================================
|
|
// Step 2 — Review
|
|
// ============================================================
|
|
private RenderFragment RenderStepReview() => __builder =>
|
|
{
|
|
if (_resolved is null)
|
|
{
|
|
<div class="alert alert-warning">Nothing resolved yet — please go back to step 1.</div>
|
|
return;
|
|
}
|
|
|
|
var seedTemplateIds = new HashSet<int>(SelectedTemplateIds());
|
|
var seedSharedScripts = new HashSet<int>(_selectedSharedScripts);
|
|
var seedExternalSystems = new HashSet<int>(_selectedExternalSystems);
|
|
// M8 (E1): sites/instances the operator ticked directly are "seed"; everything
|
|
// else the resolver pulled into Sites/Instances (e.g. an instance's owning site,
|
|
// or a site's instances) is auto-included.
|
|
var seedSiteIds = new HashSet<int>(_selectedSites);
|
|
var seedInstanceIds = new HashSet<int>(_selectedInstances);
|
|
|
|
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);
|
|
var autoSites = AutoIncluded(_resolved.Sites, seedSiteIds, s => s.Id);
|
|
var autoInstances = AutoIncluded(_resolved.Instances, seedInstanceIds, i => i.Id);
|
|
|
|
<div>
|
|
<p class="text-body-secondary">
|
|
The resolver walked your selection's dependency graph and produced the closure
|
|
below. Items under <em>Auto-included</em> were pulled in because the items you
|
|
ticked reference them; unticking
|
|
<em>Include all dependencies</em> exports the seed alone.
|
|
</p>
|
|
|
|
<div class="form-check form-switch mb-3">
|
|
<input class="form-check-input" type="checkbox" id="include-deps"
|
|
checked="@_includeDependencies"
|
|
@onchange="async e => { _includeDependencies = ((bool?)e.Value) == true; await ReresolveAsync(); }" />
|
|
<label class="form-check-label" for="include-deps">Include all dependencies</label>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-md-6" data-testid="seed-group">
|
|
<h6>Selected by you</h6>
|
|
<ul class="small list-unstyled mb-0">
|
|
@foreach (var t in _resolved.Templates.Where(t => seedTemplateIds.Contains(t.Id)).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>Template: @t.Name</li>
|
|
}
|
|
@foreach (var s in _resolved.SharedScripts.Where(s => seedSharedScripts.Contains(s.Id)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>SharedScript: @s.Name</li>
|
|
}
|
|
@foreach (var e in _resolved.ExternalSystems.Where(e => seedExternalSystems.Contains(e.Id)).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>ExternalSystem: @e.Name</li>
|
|
}
|
|
@foreach (var d in _resolved.DatabaseConnections.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>DatabaseConnection: @d.Name</li>
|
|
}
|
|
@foreach (var n in _resolved.NotificationLists.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>NotificationList: @n.Name</li>
|
|
}
|
|
@foreach (var s in _resolved.SmtpConfigs.OrderBy(s => s.Host, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>SmtpConfig: @s.Host</li>
|
|
}
|
|
@* SMS configs in the closure, mirroring SmtpConfig above; AccountSid only, never AuthToken. *@
|
|
@foreach (var s in _resolved.SmsConfigs.OrderBy(s => s.AccountSid, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>SmsConfig: @s.AccountSid</li>
|
|
}
|
|
@* Inbound API keys are not transported — methods only. *@
|
|
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>ApiMethod: @m.Name</li>
|
|
}
|
|
@foreach (var s in _resolved.Sites.Where(s => seedSiteIds.Contains(s.Id)).OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>Site: @s.Name (@s.SiteIdentifier)</li>
|
|
}
|
|
@foreach (var i in _resolved.Instances.Where(i => seedInstanceIds.Contains(i.Id)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>Instance: @i.UniqueName</li>
|
|
}
|
|
</ul>
|
|
</div>
|
|
<div class="col-md-6" data-testid="auto-group">
|
|
<h6>Auto-included (dependencies)</h6>
|
|
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count
|
|
+ autoSites.Count + autoInstances.Count + _resolved.DataConnections.Count
|
|
+ _resolved.TemplateFolders.Count == 0)
|
|
{
|
|
<div class="small text-muted fst-italic">No additional dependencies.</div>
|
|
}
|
|
else
|
|
{
|
|
<ul class="small list-unstyled mb-0">
|
|
@foreach (var f in _resolved.TemplateFolders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>TemplateFolder: @f.Name</li>
|
|
}
|
|
@foreach (var t in autoTemplates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>Template: @t.Name</li>
|
|
}
|
|
@foreach (var s in autoShared.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>SharedScript: @s.Name</li>
|
|
}
|
|
@foreach (var e in autoExternals.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>ExternalSystem: @e.Name</li>
|
|
}
|
|
@foreach (var s in autoSites.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>Site: @s.Name (@s.SiteIdentifier)</li>
|
|
}
|
|
@foreach (var i in autoInstances.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>Instance: @i.UniqueName</li>
|
|
}
|
|
@foreach (var c in _resolved.DataConnections.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
<li>DataConnection: @c.Name</li>
|
|
}
|
|
</ul>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-between mt-4">
|
|
<button class="btn btn-outline-secondary" @onclick="BackToSelect">Back</button>
|
|
<button class="btn btn-primary" @onclick="GoToEncrypt">Next</button>
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
// ============================================================
|
|
// 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",
|
|
};
|
|
|
|
<div>
|
|
@if (_secretCount > 0)
|
|
{
|
|
<div class="alert alert-warning" role="alert" data-testid="secrets-warning">
|
|
<strong>@_secretCount</strong> secret @(_secretCount == 1 ? "field" : "fields")
|
|
will be encrypted (external-system credentials, SMTP credentials, SMS auth
|
|
tokens, and database connection strings).
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-info small" role="alert" data-testid="secrets-warning">
|
|
The selected closure contains no secret fields, but the bundle's content
|
|
payload will still be encrypted in full when a passphrase is supplied.
|
|
</div>
|
|
}
|
|
|
|
<div class="mb-3">
|
|
<label for="passphrase" class="form-label">Passphrase</label>
|
|
<input id="passphrase" type="password" class="form-control"
|
|
autocomplete="new-password"
|
|
@bind="_passphrase" @bind:event="oninput" />
|
|
<div class="progress mt-1" style="height: 4px;">
|
|
<div class="progress-bar @strengthColor"
|
|
role="progressbar"
|
|
style="width: @(strength * 25)%;"
|
|
aria-valuenow="@(strength * 25)" aria-valuemin="0" aria-valuemax="100"></div>
|
|
</div>
|
|
<div class="form-text">Strength: @strengthLabel · minimum 8 characters.</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="passphrase-confirm" class="form-label">Confirm passphrase</label>
|
|
<input id="passphrase-confirm" type="password" class="form-control"
|
|
autocomplete="new-password"
|
|
@bind="_passphraseConfirm" @bind:event="oninput" />
|
|
@if (!string.IsNullOrEmpty(_passphraseConfirm) && _passphrase != _passphraseConfirm)
|
|
{
|
|
<div class="form-text text-danger">Passphrases do not match.</div>
|
|
}
|
|
</div>
|
|
|
|
<p class="small">
|
|
<a href="javascript:void(0)" class="link-danger" @onclick="OpenUnencryptedConfirm">
|
|
Export without encryption…
|
|
</a>
|
|
</p>
|
|
|
|
@if (_showUnencryptedConfirm)
|
|
{
|
|
<div class="alert alert-danger" data-testid="unencrypted-confirm">
|
|
<strong>Unencrypted export</strong> — the bundle will contain all secret fields
|
|
in plaintext. Anyone with the file can read external-system credentials, SMTP
|
|
passwords, SMS auth tokens, and database connection strings. The audit log will record this as
|
|
<code>UnencryptedBundleExport</code>.
|
|
<div class="mt-2 d-flex gap-2">
|
|
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
|
|
Yes, export without encryption
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="d-flex justify-content-between mt-4">
|
|
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
|
|
<button class="btn btn-primary"
|
|
disabled="@(!PassphraseValid)"
|
|
@onclick="StartEncryptedExportAsync">
|
|
Export
|
|
</button>
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
// ============================================================
|
|
// Step 4 — Download
|
|
// ============================================================
|
|
private RenderFragment RenderStepDownload() => __builder =>
|
|
{
|
|
<div>
|
|
@if (_downloadInProgress)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
<p class="text-body-secondary">Building bundle…</p>
|
|
}
|
|
else if (_downloadError != null)
|
|
{
|
|
<div class="alert alert-danger">
|
|
<strong>Export failed:</strong> @_downloadError
|
|
</div>
|
|
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-success" data-testid="download-summary">
|
|
<strong>Bundle ready.</strong> Your browser is downloading the file.
|
|
</div>
|
|
|
|
<dl class="row small">
|
|
<dt class="col-sm-3">Filename</dt>
|
|
<dd class="col-sm-9"><code>@_downloadFilename</code></dd>
|
|
|
|
<dt class="col-sm-3">Size</dt>
|
|
<dd class="col-sm-9">@FormatBytes(_downloadSize)</dd>
|
|
|
|
<dt class="col-sm-3">SHA-256</dt>
|
|
<dd class="col-sm-9"><code>@_downloadSha256</code></dd>
|
|
|
|
<dt class="col-sm-3">Encryption</dt>
|
|
<dd class="col-sm-9">
|
|
@if (_exportUnencrypted)
|
|
{
|
|
<span class="text-danger">Unencrypted (audited as <code>UnencryptedBundleExport</code>)</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-success">AES-256-GCM with PBKDF2-SHA256</span>
|
|
}
|
|
</dd>
|
|
</dl>
|
|
|
|
<button class="btn btn-primary" @onclick="Done">Done</button>
|
|
}
|
|
</div>
|
|
};
|
|
|
|
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";
|
|
}
|
|
}
|