feat(ui): secured-write history paging + submission age in approve dialog (arch-review S2/P3)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:51:20 -04:00
parent 655834f0b8
commit d8a39f3c35
5 changed files with 162 additions and 35 deletions
+2 -2
View File
@@ -211,8 +211,8 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
- A **Secured Writes** page (`/operations/secured-writes`) drives the **two-person** authorization workflow for writes through the MxAccess Gateway: an **Operator** initiates the write, a separate **Verifier** approves it, and only an approved write reaches the site.
- The **page itself is gated by `RequireSecuredWriteAccess`** — satisfied by any of `Operator` / `Verifier` / `Administrator`. Although the pending/history lists are read-only, they expose process-sensitive tag values, so the page is not open to every authenticated user; this aligns with the ManagementActor `ListSecuredWritesCommand` any-of gate (arch-review UA1). The submit and approve/reject sub-actions are further gated by `RequireOperator` / `RequireVerifier` respectively (below).
- **Operator (submit)** — a submit form gated by `RequireOperator`: pick the site → an **MxGateway** connection on that site → the tag path → a typed value → an optional comment. Submission inserts a `Pending` `PendingSecuredWrite` row centrally; it does **not** write anything yet.
- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing. The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason.
- **History** — terminal rows (Executed / Failed / Rejected / Expired) with the full who/when/outcome trail (operator, verifier, comments, timestamps, any execution error).
- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing, and surfaces the **submission age** (`Submitted <age> ago (<UTC timestamp>)`) so a verifier can catch a stale setpoint before it reaches the device. The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason. The pending queue is **unpaged** — it is bounded by the submission **TTL**: a `Pending` row that is neither approved nor rejected before the TTL elapses expires to the terminal **`Expired`** status and drops out of the queue into History.
- **History** — terminal rows (Executed / Failed / Rejected / **Expired**, where `Expired` is a real TTL-driven terminal status) with the full who/when/outcome trail (operator, verifier, comments, timestamps, any execution error). Terminal rows accumulate without bound, so the History table is **offset-paged** (page-size 50, Prev/Next) driven by the server's unpaged `TotalCount` (`ListSecuredWritesCommand` `Skip`/`Take`); the pager buttons disable at the real bounds.
- Every lifecycle event (submit / approve / reject / execute) is written to the central Audit Log; the rows share the `PendingSecuredWrite.Id` as `CorrelationId` so they join into one operation (see Component-ManagementService.md, Component-AuditLog.md).
- **Dev caveat**: with `DisableLogin` on, the auto-login identity holds all roles, so the two-person flow cannot be exercised end-to-end by a single user via the dev UI — no-self-approval is covered by handler tests; real two-person use requires two real identities.
@@ -221,6 +221,22 @@
</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">
<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>
@@ -234,6 +250,18 @@
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 = "";
@@ -305,9 +333,46 @@
private async Task RefreshListsAsync()
{
var all = await SecuredWriteSvc.ListAsync(status: null, siteId: null);
_pending = all.Where(w => string.Equals(w.Status, "Pending", StringComparison.OrdinalIgnoreCase)).ToList();
_history = all.Where(w => TerminalStatuses.Contains(w.Status, StringComparer.OrdinalIgnoreCase)).ToList();
// 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()
@@ -346,7 +411,9 @@
"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})\nOperator: {row.OperatorUser}",
$"Value: {row.ValueJson} ({row.ValueType})\n" +
$"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" +
$"Operator: {row.OperatorUser}",
danger: true);
if (!confirmed) return;
@@ -92,13 +92,18 @@ public interface ISecuredWriteService
/// <summary>
/// Lists secured writes, optionally filtered by <paramref name="status"/> and
/// <paramref name="siteId"/> (read-only — any authenticated user). On any failure
/// an empty list is returned so the page renders gracefully.
/// <paramref name="siteId"/> with offset paging (read-only — any authenticated user).
/// On any failure an empty result (<c>TotalCount = 0</c>) is returned so the page
/// renders gracefully. The returned <see cref="SecuredWriteListResult.TotalCount"/> is
/// the unpaged match count, so the page can drive a pager independent of page size.
/// </summary>
/// <param name="status">Status filter; <c>null</c> matches every status.</param>
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
/// <param name="skip">Rows to skip (offset paging); clamped to &gt;= 0 server-side.</param>
/// <param name="take">Maximum rows to return; clamped to 1..500 server-side.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the matching rows, newest submission first.</returns>
Task<IReadOnlyList<SecuredWriteDto>> ListAsync(
string? status, string? siteId, CancellationToken cancellationToken = default);
/// <returns>A task resolving to the matching page + total match count, newest submission first.</returns>
Task<SecuredWriteListResult> ListAsync(
string? status, string? siteId, int skip = 0, int take = 200,
CancellationToken cancellationToken = default);
}
@@ -79,21 +79,23 @@ public sealed class SecuredWriteService : ISecuredWriteService
=> DispatchAsync(new RejectSecuredWriteCommand(id, comment), cancellationToken);
/// <inheritdoc/>
public async Task<IReadOnlyList<SecuredWriteDto>> ListAsync(
string? status, string? siteId, CancellationToken cancellationToken = default)
public async Task<SecuredWriteListResult> ListAsync(
string? status, string? siteId, int skip = 0, int take = 200,
CancellationToken cancellationToken = default)
{
var response = await SendAsync(new ListSecuredWritesCommand(status, siteId), cancellationToken);
var response = await SendAsync(
new ListSecuredWritesCommand(status, siteId, skip, take), cancellationToken);
if (response is ManagementSuccess success)
{
var result = JsonSerializer.Deserialize<SecuredWriteListResult>(
success.JsonData, ResultDeserializerOptions);
return result?.Items ?? Array.Empty<SecuredWriteDto>();
return result ?? new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0);
}
// Read path: log + return empty so the queue/history tables render gracefully.
_logger.LogWarning(
"ListSecuredWrites failed: {Response}", DescribeFailure(response));
return Array.Empty<SecuredWriteDto>();
return new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0);
}
/// <summary>
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.RegularExpressions;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
@@ -48,10 +49,17 @@ public class SecuredWritesTests : BunitContext
.Returns(Task.FromResult(true));
// Empty list by default; individual tests seed the queue/history.
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(Array.Empty<SecuredWriteDto>()));
_service.ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0)));
}
/// <summary>Stubs <c>ListAsync</c> (any filter/paging args) to return <paramref name="rows"/>.</summary>
private void SeedList(IReadOnlyList<SecuredWriteDto> rows, int? totalCount = null)
=> _service.ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new SecuredWriteListResult(rows, totalCount ?? rows.Count)));
/// <summary>
/// Registers the real authorization stack + a principal with the given username and
/// roles so the page's <c>AuthorizeView Policy=...</c> regions evaluate genuinely.
@@ -165,9 +173,7 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(1, "Pending", "someone-else") }));
SeedList(new[] { Dto(1, "Pending", "someone-else") });
var cut = RenderPage();
@@ -189,9 +195,7 @@ public class SecuredWritesTests : BunitContext
// this too, but the UI surfaces it up front).
AddAuth("self", Roles.Operator, Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(1, "Pending", "self") }));
SeedList(new[] { Dto(1, "Pending", "self") });
var cut = RenderPage();
@@ -210,9 +214,7 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(7, "Pending", "someone-else") }));
SeedList(new[] { Dto(7, "Pending", "someone-else") });
_service.ApproveAsync(Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(7, "Executed", "someone-else", "verifier-user"))));
@@ -229,14 +231,13 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("viewer", Roles.Viewer);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(new[]
{
Dto(1, "Executed", "op-a", "ver-a", tag: "Pump.Setpoint"),
Dto(2, "Rejected", "op-b", "ver-b", tag: "Valve.Cmd"),
Dto(3, "Failed", "op-c", "ver-c", executionError: "device timeout", tag: "Motor.Run"),
Dto(4, "Pending", "op-d", tag: "ShouldNotAppear"),
}));
SeedList(new[]
{
Dto(1, "Executed", "op-a", "ver-a", tag: "Pump.Setpoint"),
Dto(2, "Rejected", "op-b", "ver-b", tag: "Valve.Cmd"),
Dto(3, "Failed", "op-c", "ver-c", executionError: "device timeout", tag: "Motor.Run"),
Dto(4, "Pending", "op-d", tag: "ShouldNotAppear"),
});
var cut = RenderPage();
@@ -253,6 +254,58 @@ public class SecuredWritesTests : BunitContext
});
}
// ── (d) History pager: page-size 50, driven by TotalCount ──
[Fact]
public void History_Pager_NextRequestsSecondPage_UsingTotalCount()
{
// 60 terminal rows returned but TotalCount = 120 → a second page exists, so the
// "Next" button is present + enabled and clicking it re-queries with skip: 50.
AddAuth("viewer", Roles.Viewer);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
var terminal = Enumerable.Range(1, 60)
.Select(i => Dto(i, "Executed", $"op-{i}", $"ver-{i}"))
.ToArray();
SeedList(terminal, totalCount: 120);
var cut = RenderPage();
cut.WaitForAssertion(() =>
{
var next = cut.Find("[data-test='secured-write-history-next']");
Assert.False(next.HasAttribute("disabled"));
});
cut.Find("[data-test='secured-write-history-next']").Click();
// The history page-2 fetch offsets by one page (skip: 50).
_service.Received().ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), 50, Arg.Any<int>(), Arg.Any<CancellationToken>());
}
// ── (e) Approve dialog surfaces submission age (stale-setpoint scenario) ──
[Fact]
public void Approve_ConfirmDialog_ShowsSubmissionAge()
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
var stale = Dto(9, "Pending", "someone-else") with { SubmittedAtUtc = DateTime.UtcNow.AddHours(-26) };
SeedList(new[] { stale });
_service.ApproveAsync(Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(9, "Executed", "someone-else", "verifier-user"))));
var cut = RenderPage();
cut.WaitForAssertion(() => Assert.NotNull(cut.Find("[data-test='secured-write-approve']")));
cut.Find("[data-test='secured-write-approve']").Click();
_dialog.Received(1).ConfirmAsync(
Arg.Any<string>(),
Arg.Is<string>(m => Regex.IsMatch(m, "Submitted .* ago")),
Arg.Any<bool>());
}
[Fact]
public void Viewer_DoesNotSeeOperatorOrVerifierRegions()
{