42924b6fb0
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
316 lines
15 KiB
Plaintext
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") · <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;
|
|
}
|
|
}
|
|
}
|