Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor
T
Joseph Doherty 40944a0d04 feat(centralui): row 75 — Approve is caution amber, not destructive red
The Secured Writes page rendered Approve and Reject as IDENTICAL red buttons.
Row 28 fixed the labels but left the styling, and the styling was the worse
half: at the moment of a two-person-control decision the colour carried no
discriminating information at all, and the affirmative half of the control wore
the delete affordance.

Root cause is that `danger` was a two-state flag for a three-state concept, so a
consequential-but-not-destructive action had to choose between looking like a
delete and looking like the safe default. Adds the missing middle rung:

    DialogSeverity { Normal -> btn-primary, Caution -> btn-warning,
                     Destructive -> btn-danger }

DECISION. Approve -> Caution (amber): approving executes a real write to live
plant equipment, so it is not a benign primary action, but it is not destroying
anything either. Reject KEEPS Destructive (red): it is terminal and irreversible
for the request — the Operator must resubmit — and holding it red is what keeps
the two opposite decisions visually distinct now that Approve is amber. Dropping
Reject to neutral was considered and rejected: it would invert the bias, making
declining look like the safe default. Colour is never the only signal — both
buttons carry their verb.

Only Destructive inherits the row-28 "Delete" label fallback; an unlabelled
Caution reads "Confirm", since a caution action is by definition not a delete —
otherwise row 28 would come back through the new door.

ADDITIVE OVERLOAD, deliberately. The boolean form stays the signature for all
~34 existing call sites, which are genuinely two-state, so nothing in src/
outside SecuredWrites.razor changed. Adding a fifth optional parameter to the
boolean form instead would have silently un-matched every existing four-argument
NSubstitute stub — the exact row-28 gotcha (null Task, caller NREs, no compile
error). The overload cannot: the only breakage was six hand-written IDialogService
fakes failing to compile, which is the loud failure mode. That gotcha did land
once, in SecuredWritesTests' own stub, and is now stubbed on both overloads with
a comment saying why.

DialogState's `bool Danger` became `DialogSeverity Severity` rather than gaining
a second field — it is read only by DialogHost, and one field cannot disagree
with itself.

Fail-before, both halves:
  - Approve call site reverted to `danger: true` -> both new Approve pins fail.
  - Caution remapped to btn-danger in DialogHost -> the amber styling pin fails.

Tests: DialogSeverityStylingTests renders the REAL DialogService behind a real
DialogHost (FailoverConfirmLabelTests' harness), so it pins what the user sees,
not what an argument was — all three severities, both label fallbacks, and the
boolean adapter. SecuredWritesTests pins that the page asks for Caution, with a
negative control that it did NOT also go through the boolean form (without which
the assertion would pass on a call site left at danger:true). Reject's red is
pinned too, so a later "make this page less alarming" pass cannot flatten both
to one colour.

No Playwright selectors needed updating — the suite has no Secured Writes
coverage, and no `.btn-danger` selector elsewhere targets this page.

Docs: Component-CentralUI.md gains the severity contract under Technology (next
to the row-28 label contract) plus the decision on the Secured Writes section.

CentralUI.Tests: 1055/1055 green.
2026-08-15 21:33:08 -04:00

509 lines
25 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/operations/secured-writes"
@* arch-review UA1: the Secured Writes history exposes process-sensitive tag
values, so the page is gated to the Secured Write roles + Administrator
(any-of), aligning with the ManagementActor ListSecuredWrites gate. *@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireSecuredWriteAccess)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Security
@inject ISecuredWriteService SecuredWriteSvc
@inject ISiteRepository SiteRepository
@inject AuthenticationStateProvider AuthStateProvider
@inject IDialogService Dialog
@inject ILogger<SecuredWrites> Logger
@* Secured Writes. Two-person workflow:
an Operator submits a write against an MxGateway connection; a DIFFERENT Verifier
approves (which relays the write to the device) or rejects. This page only SUBMITS
commands — the server (ManagementActor) enforces roles, the no-self-approval guard,
the device relay, and the audit trail. Dev caveat: with DisableLogin on, one identity
holds every role, so both role regions render; approve/reject on a row this user
submitted is disabled (the server rejects it too, but the UI surfaces it up front). *@
<div class="container-fluid mt-3" data-test="secured-writes">
<ToastNotification @ref="_toast" />
<div class="d-flex align-items-baseline flex-wrap mb-3">
<h4 class="mb-0 me-3">Secured Writes</h4>
</div>
@* ── Operator region: submit form ───────────────────────────────────── *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireOperator">
<Authorized Context="operatorContext">
<div class="card mb-4" data-test="secured-write-submit-region">
<div class="card-header py-2"><strong>Submit a secured write</strong> <span class="text-muted small">(Operator)</span></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label form-label-sm">Site</label>
<select class="form-select form-select-sm" @bind="_formSiteIdentifier" data-test="secured-write-site">
<option value="">— select site —</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label form-label-sm">MxGateway connection</label>
<select class="form-select form-select-sm" @bind="_formConnectionName" data-test="secured-write-connection"
disabled="@(MxConnectionsForForm.Count == 0)">
<option value="">@(MxConnectionsForForm.Count == 0 ? "— no MxGateway connections —" : "— select connection —")</option>
@foreach (var conn in MxConnectionsForForm)
{
<option value="@conn.Name">@conn.Name</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label form-label-sm">Tag path</label>
<div class="input-group input-group-sm">
<input class="form-control form-control-sm" @bind="_formTagPath"
placeholder="e.g. PLC1.Pump.Setpoint" data-test="secured-write-tagpath" />
<button class="btn btn-outline-secondary" type="button"
disabled="@(!CanBrowse)"
title="@(CanBrowse ? "Browse tags" : "Pick a site and MxGateway connection first")"
@onclick="OpenBrowser" data-test="secured-write-browse">Browse…</button>
</div>
</div>
<div class="col-md-4">
<label class="form-label form-label-sm">Value</label>
<input class="form-control form-control-sm" @bind="_formValue" data-test="secured-write-value" />
</div>
<div class="col-md-4">
<label class="form-label form-label-sm">Data type</label>
<select class="form-select form-select-sm" @bind="_formValueType" data-test="secured-write-datatype">
@foreach (var dt in Enum.GetNames<DataType>())
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label form-label-sm">Comment <span class="text-muted">(optional)</span></label>
<input class="form-control form-control-sm" @bind="_formComment" data-test="secured-write-comment" />
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary btn-sm" @onclick="Submit"
disabled="@(_submitting || !CanSubmit)" data-test="secured-write-submit">
@if (_submitting)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Submit for verification
</button>
</div>
<NodeBrowserDialog @ref="_browserRef"
SiteId="@_formSiteIdentifier"
ConnectionName="@_formConnectionName"
ShowSearch="false"
OnNodeSelected="OnTagPicked" />
</div>
</div>
</Authorized>
</AuthorizeView>
@* ── Verifier region: pending queue ─────────────────────────────────── *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireVerifier">
<Authorized Context="verifierContext">
<div class="card mb-4" data-test="secured-write-pending-region">
<div class="card-header py-2"><strong>Pending verification</strong> <span class="text-muted small">(Verifier)</span></div>
<div class="card-body p-0">
@if (_pending.Count == 0)
{
<p class="text-muted small p-3 mb-0">No pending secured writes.</p>
}
else
{
<table class="table table-sm table-hover mb-0 align-middle">
<thead>
<tr>
<th>Site</th>
<th>Connection</th>
<th>Tag</th>
<th>Value</th>
<th>Type</th>
<th>Operator</th>
<th>Submitted</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var row in _pending)
{
var isOwn = string.Equals(row.OperatorUser, _currentUsername, StringComparison.OrdinalIgnoreCase);
<tr @key="row.Id" data-test="secured-write-pending-row">
<td>@row.SiteId</td>
<td>@row.ConnectionName</td>
@* Tag path and value are device/operator supplied and unbounded.
.cell-clip is display:block, so it goes on an inner element —
putting it on the <td> would drop the cell out of the row. *@
<td><code class="cell-clip" title="@row.TagPath">@row.TagPath</code></td>
<td><span class="cell-clip" title="@row.ValueJson">@row.ValueJson</span></td>
<td>@row.ValueType</td>
<td>@row.OperatorUser</td>
<td class="text-nowrap">@row.SubmittedAtUtc.ToString("u")</td>
<td class="text-end text-nowrap">
@if (isOwn)
{
<span class="text-muted small me-2" data-test="secured-write-own-note">your submission</span>
}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success"
@onclick="() => Approve(row)"
disabled="@(isOwn || _actionInProgress)"
data-test="secured-write-approve">
Approve
</button>
<button class="btn btn-outline-danger"
@onclick="() => Reject(row)"
disabled="@(isOwn || _actionInProgress)"
data-test="secured-write-reject">
Reject
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</Authorized>
</AuthorizeView>
@* ── History region: terminal rows (any authenticated user) ─────────── *@
<div class="card" data-test="secured-write-history-region">
<div class="card-header py-2"><strong>History</strong> <span class="text-muted small">(decided / executed)</span></div>
<div class="card-body p-0">
@if (_history.Count == 0)
{
<p class="text-muted small p-3 mb-0">No completed secured writes.</p>
}
else
{
<table class="table table-sm mb-0 align-middle">
<thead>
<tr>
<th>Site</th>
<th>Connection</th>
<th>Tag</th>
<th>Value</th>
<th>Status</th>
<th>Operator</th>
<th>Verifier</th>
<th>Submitted</th>
<th>Decided</th>
<th>Executed</th>
<th>Error</th>
</tr>
</thead>
<tbody>
@foreach (var row in _history)
{
<tr @key="row.Id" data-test="secured-write-history-row">
<td>@row.SiteId</td>
<td>@row.ConnectionName</td>
@* 11 columns here, and the three timestamps to the right are
text-nowrap — so the free-text cells clip narrow (-sm). *@
<td><code class="cell-clip cell-clip-sm" title="@row.TagPath">@row.TagPath</code></td>
<td><span class="cell-clip cell-clip-sm" title="@row.ValueJson">@row.ValueJson</span></td>
<td><span class="badge @StatusBadgeClass(row.Status)">@row.Status</span></td>
<td>@row.OperatorUser</td>
<td>@(row.VerifierUser ?? "—")</td>
<td class="text-nowrap">@row.SubmittedAtUtc.ToString("u")</td>
<td class="text-nowrap">@(row.DecidedAtUtc?.ToString("u") ?? "—")</td>
<td class="text-nowrap">@(row.ExecutedAtUtc?.ToString("u") ?? "—")</td>
@* Raw device/MxGateway exception text in the last column — clamp to
two lines so one bad message can't set the whole table's width. *@
<td class="text-danger small">
<span class="cell-clamp-2" title="@row.ExecutionError">@(row.ExecutionError ?? "")</span>
</td>
</tr>
}
</tbody>
</table>
}
@if (_historyTotalCount > HistoryPageSize)
{
<div class="d-flex justify-content-between align-items-center p-2 border-top small"
data-test="secured-write-history-pager">
<span class="text-muted">
Showing @(_historyPage * HistoryPageSize + 1)–@Math.Min((_historyPage + 1) * HistoryPageSize, _historyTotalCount)
of @_historyTotalCount
</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="HistoryPrev"
disabled="@(_historyPage == 0)" data-test="secured-write-history-prev">Prev</button>
<button class="btn btn-outline-secondary" @onclick="HistoryNext"
disabled="@(!HasNextHistoryPage)" data-test="secured-write-history-next">Next</button>
</div>
</div>
}
</div>
</div>
</div>
@code {
private ToastNotification _toast = default!;
private IReadOnlyList<Site> _sites = Array.Empty<Site>();
private readonly Dictionary<string, IReadOnlyList<DataConnection>> _connectionsBySite = new(StringComparer.Ordinal);
private List<SecuredWriteDto> _pending = new();
private List<SecuredWriteDto> _history = new();
private string _currentUsername = ClaimsPrincipalExtensions.UnknownUser;
// History pager. Terminal rows can accumulate without bound, so the History table is
// offset-paged (page-size 50); the Pending queue stays unpaged because it is now
// bounded by the submission TTL (arch-review S2). _historyTotalCount is the server's
// unpaged match count, so Prev/Next disable at the real bounds.
private const int HistoryPageSize = 50;
// Pending is bounded by the TTL; fetch a generous cap in one unpaged read.
private const int PendingFetchLimit = 500;
private int _historyPage; // 0-based
private int _historyTotalCount;
private bool HasNextHistoryPage => (_historyPage + 1) * HistoryPageSize < _historyTotalCount;
// Submit-form state.
private string _formSiteIdentifier = "";
private string _formConnectionName = "";
private string _formTagPath = "";
private string _formValue = "";
private string _formValueType = nameof(DataType.Boolean);
private string _formComment = "";
private NodeBrowserDialog? _browserRef;
private bool CanBrowse =>
!string.IsNullOrWhiteSpace(_formSiteIdentifier)
&& !string.IsNullOrWhiteSpace(_formConnectionName);
private async Task OpenBrowser()
{
if (!CanBrowse || _browserRef is null) return;
await _browserRef.ShowAsync(_formSiteIdentifier, _formConnectionName, _formTagPath);
}
private void OnTagPicked(BrowseNode node)
{
_formTagPath = node.NodeId;
if (SecuredWriteDataTypeMapper.TryMap(node.DataType, out var dt))
_formValueType = dt.ToString();
}
private bool _submitting;
private bool _actionInProgress;
/// <summary>Terminal statuses surfaced in the History table.</summary>
private static readonly string[] TerminalStatuses = ["Executed", "Failed", "Rejected", "Expired"];
private IReadOnlyList<DataConnection> MxConnectionsForForm =>
string.IsNullOrEmpty(_formSiteIdentifier) || !_connectionsBySite.TryGetValue(_formSiteIdentifier, out var conns)
? Array.Empty<DataConnection>()
: conns.Where(c => string.Equals(c.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase)).ToList();
private bool CanSubmit =>
!string.IsNullOrWhiteSpace(_formSiteIdentifier)
&& !string.IsNullOrWhiteSpace(_formConnectionName)
&& !string.IsNullOrWhiteSpace(_formTagPath)
&& !string.IsNullOrWhiteSpace(_formValue);
protected override async Task OnInitializedAsync()
{
_currentUsername = await AuthStateProvider.GetCurrentUsernameAsync();
await LoadSitesAsync();
await RefreshListsAsync();
}
private async Task LoadSitesAsync()
{
try
{
_sites = await SiteRepository.GetAllSitesAsync();
foreach (var site in _sites)
{
_connectionsBySite[site.SiteIdentifier] =
await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load sites/connections for the Secured Writes form.");
_toast.ShowError("Failed to load sites.");
}
}
private async Task RefreshListsAsync()
{
// Pending queue: unpaged (bounded by the submission TTL), filtered to Pending.
var pending = await SecuredWriteSvc.ListAsync(
status: "Pending", siteId: null, skip: 0, take: PendingFetchLimit);
_pending = pending.Items
.Where(w => string.Equals(w.Status, "Pending", StringComparison.OrdinalIgnoreCase))
.ToList();
// History: terminal rows, offset-paged. TotalCount drives the pager bounds.
var history = await SecuredWriteSvc.ListAsync(
status: null, siteId: null, skip: _historyPage * HistoryPageSize, take: HistoryPageSize);
_history = history.Items
.Where(w => TerminalStatuses.Contains(w.Status, StringComparer.OrdinalIgnoreCase))
.ToList();
_historyTotalCount = history.TotalCount;
}
private async Task HistoryNext()
{
if (!HasNextHistoryPage) return;
_historyPage++;
await RefreshListsAsync();
}
private async Task HistoryPrev()
{
if (_historyPage == 0) return;
_historyPage--;
await RefreshListsAsync();
}
/// <summary>Renders a coarse, human-readable age for a submission timestamp.</summary>
private static string FormatAge(DateTime submittedUtc)
{
var age = DateTime.UtcNow - submittedUtc;
if (age < TimeSpan.Zero) age = TimeSpan.Zero;
if (age.TotalMinutes < 1) return "less than a minute";
if (age.TotalHours < 1) { var m = (int)age.TotalMinutes; return $"{m} minute{(m == 1 ? "" : "s")}"; }
if (age.TotalDays < 1) { var h = (int)age.TotalHours; return $"{h} hour{(h == 1 ? "" : "s")}"; }
var d = (int)age.TotalDays;
return $"{d} day{(d == 1 ? "" : "s")}";
}
private async Task Submit()
{
if (!CanSubmit) return;
_submitting = true;
try
{
var result = await SecuredWriteSvc.SubmitAsync(
_formSiteIdentifier, _formConnectionName, _formTagPath,
_formValue, _formValueType,
string.IsNullOrWhiteSpace(_formComment) ? null : _formComment);
if (result.Success)
{
_toast.ShowSuccess("Secured write submitted for verification.");
_formTagPath = "";
_formValue = "";
_formComment = "";
await RefreshListsAsync();
}
else
{
_toast.ShowError(result.Error ?? "Submit failed.");
}
}
finally
{
_submitting = false;
}
}
private async Task Approve(SecuredWriteDto row)
{
var confirmed = await Dialog.ConfirmAsync(
"Approve secured write",
$"Approving will write to the device.\n\n" +
$"Site: {row.SiteId}\nConnection: {row.ConnectionName}\nTag: {row.TagPath}\n" +
$"Value: {row.ValueJson} ({row.ValueType})\n" +
$"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" +
$"Operator: {row.OperatorUser}",
// Caution, NOT danger (deferred-work register row 75). Approving executes a
// real write to live plant equipment, so this is not a benign primary action
// — but it is not destroying anything either, and red made it look like a
// delete while rendering it identical to the Reject button right beside it.
// Amber keeps the beat of hesitation the write deserves and restores the
// colour contrast between the two opposite decisions.
DialogSeverity.Caution,
confirmLabel: "Approve");
if (!confirmed) return;
_actionInProgress = true;
try
{
var result = await SecuredWriteSvc.ApproveAsync(row.Id, comment: null);
if (result.Success)
{
var status = result.Dto?.Status ?? "decided";
if (string.Equals(status, "Executed", StringComparison.OrdinalIgnoreCase))
{
_toast.ShowSuccess("Secured write approved and executed.");
}
else
{
_toast.ShowWarning($"Secured write approved but not executed (status: {status}).");
}
await RefreshListsAsync();
}
else
{
_toast.ShowError(result.Error ?? "Approve failed.");
}
}
finally
{
_actionInProgress = false;
}
}
private async Task Reject(SecuredWriteDto row)
{
var confirmed = await Dialog.ConfirmAsync(
"Reject secured write",
$"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?",
// Reject KEEPS danger styling (register row 75). It is terminal and
// irreversible for the request — the Operator has to resubmit — and holding
// it red is what makes the pair readable: with Approve amber, the two
// opposite decisions no longer look alike. Dropping Reject to a neutral
// button would just invert the bias, making declining look like the safe
// default.
danger: true,
confirmLabel: "Reject");
if (!confirmed) return;
_actionInProgress = true;
try
{
var result = await SecuredWriteSvc.RejectAsync(row.Id, comment: null);
if (result.Success)
{
_toast.ShowSuccess("Secured write rejected.");
await RefreshListsAsync();
}
else
{
_toast.ShowError(result.Error ?? "Reject failed.");
}
}
finally
{
_actionInProgress = false;
}
}
private static string StatusBadgeClass(string status) => status switch
{
"Executed" => "text-bg-success",
"Failed" => "text-bg-danger",
"Rejected" => "text-bg-secondary",
"Expired" => "text-bg-secondary",
_ => "text-bg-light"
};
}