Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor
T
Joseph Doherty 42924b6fb0 ui(adminui): clusters pages — button groups, notice-alert tint, prose trim
Task 1 of the AdminUI UI sweep. Behavior-preserving: only wrappers, classes
and prose change — every @onclick, disabled, href and arm->confirm flow is
untouched.

Buttons -> Bootstrap btn-group:
- ClusterOverview page-head Edit cluster + Deployments pair
- Edit-form feet (Save+Cancel grouped; ms-auto Delete stays outside):
  ClusterEdit, NodeEdit, NewCluster, AclEdit
- ClusterRedundancy confirm-failover Confirm+Cancel (ms-2 dropped)
- AclEdit permission-bundle presets: btn-link + middot separators become an
  outline-secondary btn-group

Inline style="border-color:var(--alert)" (a no-op — that token is defined
nowhere) replaced by the .notice-alert class: NodeEdit, AclEdit, ClusterEdit.

Prose (unconditional documentation deleted; state banners/legends kept):
- ClusterRedundancy: removed the stale RedundancyStateActor "on each admin
  node" block (cluster-scoped singleton on driver nodes since mesh Phase 6)
  which also hyperlinked an unserved repo path; trimmed the mesh-split
  explanation to its data-provenance first sentence
- ClusterAcls: removed the Security:Acl:Enabled mode walkthrough and the
  grant/principal explanation (both in docs/security.md); kept the
  unresolved-chip decoder and trimmed the empty state to one sentence
- AclEdit: removed its near-duplicate of that walkthrough
- ClusterAudit: dropped the stale (F3) label and the EventId/CorrelationId
  dedup sentence; kept the page-size + Details-JSON caption
- ClusterNamespaces / NamespaceEdit: retired-in-v3 placeholders reworded to
  past tense — Batches 2/3 shipped, /raw and /uns are live
2026-08-10 16:31:25 -04:00

316 lines
15 KiB
Plaintext

@page "/clusters/{ClusterId}/acls/new"
@page "/clusters/{ClusterId}/acls/{NodeAclId}"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAclScopeService Scopes
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New ACL grant" : "Edit ACL grant") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="acls" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">ACL <span class="mono">@NodeAclId</span> not found.</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger" />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Grant</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="aid">NodeAclId</label>
<InputText id="aid" @bind-Value="_form.NodeAclId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="grp">Principal</label>
<input id="grp" class="form-control form-control-sm mono" list="acl-principals"
placeholder="scadabridge-dcl"
@bind="_form.Principal" @bind:after="RefreshPrincipalAsync" />
<datalist id="acl-principals">
@foreach (var p in _knownPrincipals)
{
<option value="@p"></option>
}
</datalist>
<div class="form-text">
@if (string.IsNullOrWhiteSpace(_form.Principal))
{
<span>
An API key's principal, or the reserved
<span class="mono">@OpcUaPrincipals.Anonymous</span> principal that an
anonymous session authenticates as. Mint keys on
<a href="/apikeys">/apikeys</a>.
</span>
}
else if (_principal.IsAnonymous)
{
<span class="text-success">
✓ The reserved anonymous principal — every session admitted without a
key authenticates as this.
</span>
}
else if (_principal.EnabledKeys > 0)
{
<span class="text-success">
✓ @_principal.EnabledKeys active API key@(_principal.EnabledKeys == 1 ? "" : "s")
@(_principal.DisabledKeys > 0 ? $" (+{_principal.DisabledKeys} revoked or expired)" : "")
authenticate@(_principal.EnabledKeys == 1 ? "s" : "") as this principal.
</span>
}
else
{
<span class="text-warning">
⚠ No active API key uses this principal@(_principal.DisabledKeys > 0 ? " (only revoked or expired ones)" : "") —
the grant is valid but currently authorizes nobody. That is fine if the
key is still to be minted; otherwise check the spelling.
</span>
}
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Scope</label>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="chip chip-idle">@_form.ScopeKind</span>
<span class="mono small">@(string.IsNullOrEmpty(_form.ScopeId) ? "(whole cluster)" : _form.ScopeId)</span>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="OpenPicker">
Choose scope…
</button>
</div>
<div class="form-text @(_scope.Ok ? "text-success" : "text-danger")">
@(_scope.Ok ? "✓ " : "✗ ")@_scope.Message
</div>
<div class="form-text">
Permissions inherit downward: a grant on a device covers every tag beneath it.
Picking the scope from the tree is what keeps the kind and the target in step —
a kind that disagrees with its target resolves to nothing and authorizes nobody.
</div>
</div>
<div class="mb-3">
<label class="form-label">Permissions</label>
<div>
@foreach (var bit in PermissionBits)
{
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input"
id="perm-@bit"
checked="@_form.HasPerm(bit)"
@onchange="e => _form.SetPerm(bit, (bool)e.Value!)" />
<label class="form-check-label" for="perm-@bit">@bit</label>
</div>
}
</div>
<div class="form-text mt-2">
Bundles:
<div class="btn-group btn-group-sm ms-1" role="group">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.ReadOnly">ReadOnly</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.Operator">Operator</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.Engineer">Engineer</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.Admin">Admin</button>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice notice-alert mt-3">@_error</div> }
<div class="mt-3 d-flex gap-2">
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary">Cancel</a>
</div>
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
</div>
</EditForm>
<AclScopePickerModal Visible="_pickerVisible" ClusterId="@ClusterId"
OnPicked="ScopePicked" OnCancel="() => _pickerVisible = false" />
}
@code {
private static readonly NodePermissions[] PermissionBits =
[
NodePermissions.Browse, NodePermissions.Read, NodePermissions.Subscribe, NodePermissions.HistoryRead,
NodePermissions.WriteOperate, NodePermissions.WriteTune, NodePermissions.WriteConfigure,
NodePermissions.AlarmRead, NodePermissions.AlarmAcknowledge, NodePermissions.AlarmConfirm, NodePermissions.AlarmShelve,
NodePermissions.MethodCall,
];
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? NodeAclId { get; set; }
private bool IsNew => string.IsNullOrEmpty(NodeAclId);
private FormModel _form = new();
private NodeAcl? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
private bool _pickerVisible;
private IReadOnlyList<string> _knownPrincipals = Array.Empty<string>();
private PrincipalUsage _principal;
private AclScopeCheck _scope;
protected override async Task OnInitializedAsync()
{
if (!IsNew)
{
await using var db = await DbFactory.CreateDbContextAsync();
_existing = await db.NodeAcls.AsNoTracking().FirstOrDefaultAsync(
a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
if (_existing is not null)
{
_form = new FormModel
{
NodeAclId = _existing.NodeAclId,
Principal = _existing.Principal,
ScopeKind = _existing.ScopeKind,
ScopeId = _existing.ScopeId,
PermissionFlags = _existing.PermissionFlags,
Notes = _existing.Notes,
RowVersion = _existing.RowVersion,
};
}
}
_knownPrincipals = await Scopes.LoadKnownPrincipalsAsync();
await RefreshPrincipalAsync();
await RefreshScopeAsync();
_loaded = true;
}
private void OpenPicker()
{
_pickerVisible = true;
StateHasChanged();
}
private async Task ScopePicked(AclScopeNode node)
{
// The picker supplies both halves at once — the pairing is what stops a kind naming one entity
// type while its id names another.
_form.ScopeKind = node.ScopeKind!.Value;
_form.ScopeId = node.ScopeId;
_pickerVisible = false;
await RefreshScopeAsync();
}
private async Task RefreshPrincipalAsync() =>
_principal = await Scopes.CheckPrincipalAsync(_form.Principal ?? "");
private async Task RefreshScopeAsync() =>
_scope = await Scopes.CheckScopeAsync(ClusterId, _form.ScopeKind, _form.ScopeId);
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
// Re-check rather than trust the cached verdict: an existing row may have been loaded with a
// scope whose entity has since been deleted, and the operator may never have touched the
// picker. Blocking here is what stops the page saving a grant the deployment would reject.
await RefreshScopeAsync();
if (!_scope.Ok) { _error = $"Scope: {_scope.Message}"; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.NodeAcls.AnyAsync(a => a.NodeAclId == _form.NodeAclId))
{ _error = $"ACL '{_form.NodeAclId}' already exists."; return; }
db.NodeAcls.Add(new NodeAcl
{
NodeAclId = _form.NodeAclId,
ClusterId = ClusterId,
Principal = _form.Principal,
ScopeKind = _form.ScopeKind,
ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId,
PermissionFlags = _form.PermissionFlags,
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
});
}
else
{
var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Principal = _form.Principal;
entity.ScopeKind = _form.ScopeKind;
entity.ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId;
entity.PermissionFlags = _form.PermissionFlags;
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/acls");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/acls"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.NodeAcls.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/acls");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were viewing it."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string NodeAclId { get; set; } = "";
[Required] public string Principal { get; set; } = "";
public NodeAclScopeKind ScopeKind { get; set; } = NodeAclScopeKind.Cluster;
public string? ScopeId { get; set; }
public NodePermissions PermissionFlags { get; set; } = NodePermissions.None;
public string? Notes { get; set; }
public byte[] RowVersion { get; set; } = [];
public bool HasPerm(NodePermissions bit) => PermissionFlags.HasFlag(bit);
public void SetPerm(NodePermissions bit, bool on)
{
if (on) PermissionFlags |= bit;
else PermissionFlags &= ~bit;
}
}
}