Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor
Joseph Doherty 7a5b535cd6 Phase 1 Stream E Admin UI — finish Blazor pages so operators can run the draft → publish → rollback workflow end-to-end without hand-executing SQL. Adds eight new scoped services that wrap the Configuration stored procs + managed validators: EquipmentService (CRUD with auto-derived EquipmentId per decision #125), UnsService (areas + lines), NamespaceService, DriverInstanceService (generic JSON DriverConfig editor per decision #94 — per-driver schema validation lands in each driver's phase), NodeAclService (grant + revoke with bundled-preset permission sets; full per-flag editor + bulk-grant + permission simulator deferred to v2.1), ReservationService (fleet-wide active + released reservation inspector + FleetAdmin-only sp_ReleaseExternalIdReservation wrapper with required-reason invariant), DraftValidationService (hydrates a DraftSnapshot from the draft's rows plus prior-cluster Equipment + active reservations, runs the managed DraftValidator to surface every rule in one pass for inline validation panel), AuditLogService (recent ConfigAuditLog reader). Pages: /clusters list with create-new shortcut; /clusters/new wizard that creates the cluster row + initial empty draft in one go; /clusters/{id} detail with 8 tabs (Overview / Generations / Equipment / UNS Structure / Namespaces / Drivers / ACLs / Audit) — tabs that write always target the active draft, published generations stay read-only; /clusters/{id}/draft/{gen} editor with live validation panel (errors list with stable code + message + context; publish button disabled while any error exists) and tab-embedded sub-components; /clusters/{id}/draft/{gen}/diff three-column view backed by sp_ComputeGenerationDiff with Added/Removed/Modified badges; Generations tab with per-row rollback action wired to sp_RollbackToGeneration; /reservations FleetAdmin-only page (CanPublish policy) with active + released lists and a modal release dialog that enforces non-empty reason and round-trips through sp_ReleaseExternalIdReservation; /login scaffold with stub credential accept + FleetAdmin-role cookie issuance (real LDAP bind via the ScadaLink-parity LdapAuthService is deferred until live GLAuth integration — marked in the login view and in the Phase 1 partial-exit TODO). Layout: sidebar gets Overview / Clusters / Reservations + AuthorizeView with signed-in username + roles + sign-out POST to /auth/logout; cascading authentication state registered for <AuthorizeView> to work in RenderMode.InteractiveServer. Integration testing: AdminServicesIntegrationTests creates a throwaway per-run database (same pattern as the Configuration test fixture), applies all three migrations, and exercises (1) create-cluster → add-namespace+UNS+driver+equipment → validate (expects zero errors) → publish (expects Published status) → rollback (expects one new Published + at least one Superseded); (2) cross-cluster namespace binding draft → validates to BadCrossClusterNamespaceBinding per decision #122. Old flat Components/Pages/Clusters.razor moved to Components/Pages/Clusters/ClustersList.razor so the Clusters folder can host tab sub-components without the razor generator creating a type-and-namespace collision. Dev appsettings.json connection string switched from Integrated Security to sa auth to match the otopcua-mssql container on port 14330 (remapped from 1433 to coexist with the native MSSQL14 Galaxy ZB instance). Browser smoke test completed: home page, clusters list, new-cluster form, cluster detail with a seeded row, reservations (redirected to login for anon user) all return 200 / 302-to-login as expected; full solution 928 pass / 1 pre-existing Phase 0 baseline failure. Phase 1 Stream E items explicitly deferred with TODOs: CSV import for Equipment, SignalR FleetStatusHub + AlertHub real-time push, bulk-grant workflow, permission-simulator trie, merge-equipment draft, AppServer-via-OI-Gateway end-to-end smoke test (decision #142), and the real LDAP bind replacing the Login page stub.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:52:42 -04:00

127 lines
5.3 KiB
Plaintext

@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject NodeAclService AclSvc
<div class="d-flex justify-content-between mb-3">
<h4>Access-control grants</h4>
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button>
</div>
@if (_acls is null) { <p>Loading…</p> }
else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> }
else
{
<table class="table table-sm">
<thead><tr><th>LDAP group</th><th>Scope</th><th>Scope ID</th><th>Permissions</th><th></th></tr></thead>
<tbody>
@foreach (var a in _acls)
{
<tr>
<td>@a.LdapGroup</td>
<td>@a.ScopeKind</td>
<td><code>@(a.ScopeId ?? "-")</code></td>
<td><code>@a.PermissionFlags</code></td>
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
</tr>
}
</tbody>
</table>
}
@if (_showForm)
{
<div class="card">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">LDAP group</label>
<input class="form-control" @bind="_group"/>
</div>
<div class="col-md-4">
<label class="form-label">Scope kind</label>
<select class="form-select" @bind="_scopeKind">
@foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> }
</select>
</div>
<div class="col-md-4">
<label class="form-label">Scope ID (empty for Cluster-wide)</label>
<input class="form-control" @bind="_scopeId"/>
</div>
<div class="col-12">
<label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label>
<select class="form-select" @bind="_preset">
<option value="Read">Read (Browse + Read)</option>
<option value="WriteOperate">Read + Write Operate</option>
<option value="Engineer">Read + Write Tune + Write Configure</option>
<option value="AlarmAck">Read + Alarm Ack</option>
<option value="Full">Full (every flag)</option>
</select>
</div>
</div>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
<div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
</div>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private List<NodeAcl>? _acls;
private bool _showForm;
private string _group = string.Empty;
private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster;
private string _scopeId = string.Empty;
private string _preset = "Read";
private string? _error;
protected override async Task OnParametersSetAsync() =>
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
private NodePermissions ResolvePreset() => _preset switch
{
"Read" => NodePermissions.Browse | NodePermissions.Read,
"WriteOperate" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate,
"Engineer" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune | NodePermissions.WriteConfigure,
"AlarmAck" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge,
"Full" => unchecked((NodePermissions)(-1)),
_ => NodePermissions.Browse | NodePermissions.Read,
};
private async Task SaveAsync()
{
_error = null;
if (string.IsNullOrWhiteSpace(_group)) { _error = "LDAP group is required"; return; }
var scopeId = _scopeKind == NodeAclScopeKind.Cluster ? null
: string.IsNullOrWhiteSpace(_scopeId) ? null : _scopeId;
if (_scopeKind != NodeAclScopeKind.Cluster && scopeId is null)
{
_error = $"ScopeId required for {_scopeKind}";
return;
}
try
{
await AclSvc.GrantAsync(GenerationId, ClusterId, _group, _scopeKind, scopeId,
ResolvePreset(), notes: null, CancellationToken.None);
_group = string.Empty; _scopeId = string.Empty;
_showForm = false;
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
}
catch (Exception ex) { _error = ex.Message; }
}
private async Task RevokeAsync(Guid rowId)
{
await AclSvc.RevokeAsync(rowId, CancellationToken.None);
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
}
}