40944a0d04
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.
509 lines
25 KiB
Plaintext
509 lines
25 KiB
Plaintext
@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"
|
||
};
|
||
}
|