feat(adminui): trust/untrust/delete actions on /certificates (FleetAdmin)

This commit is contained in:
Joseph Doherty
2026-06-18 05:11:12 -04:00
parent e8769fd8a8
commit 8c429c3131
2 changed files with 93 additions and 8 deletions
@@ -3,7 +3,11 @@
@rendermode RenderMode.InteractiveServer
@using System.Security.Cryptography.X509Certificates
@using Microsoft.Extensions.Configuration
@using Microsoft.AspNetCore.Components.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Certificates
@inject IConfiguration Config
@inject CertificateStoreManager CertManager
@implements IDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">OPC UA certificates</h4>
@@ -22,6 +26,18 @@
}
else
{
@if (_statusMsg is not null)
{
<section class="panel @(_statusError ? "error" : "notice") rise mt-3">@_statusMsg</section>
}
@if (_pending is { } p)
{
<section class="panel notice rise mt-3">
Confirm <strong>@p.Verb</strong> of <span class="mono small">@p.Subject</span>?
<button class="btn btn-sm btn-primary" @onclick="ConfirmAction">Confirm</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAction">Cancel</button>
</section>
}
@foreach (var store in _rows)
{
<section class="panel rise mt-3" style="animation-delay:.08s">
@@ -51,6 +67,10 @@ else
<th>Thumbprint</th>
<th>Not before</th>
<th>Not after</th>
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
{
<th>Actions</th>
}
</tr>
</thead>
<tbody>
@@ -62,6 +82,24 @@ else
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
<td>@c.NotBefore.ToString("u")</td>
<td>@c.NotAfter.ToString("u")</td>
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
{
<td>
<AuthorizeView Policy="FleetAdmin">
<Authorized>
@if (store.Kind == StoreKind.Rejected)
{
<button class="btn btn-sm btn-outline-primary" @onclick='() => RequestAction(store.Kind, c, "trust")'>Trust</button>
}
else
{
<button class="btn btn-sm btn-outline-secondary" @onclick='() => RequestAction(store.Kind, c, "untrust")'>Untrust</button>
}
<button class="btn btn-sm btn-outline-danger" @onclick='() => RequestAction(store.Kind, c, "delete")'>Delete</button>
</Authorized>
</AuthorizeView>
</td>
}
</tr>
}
</tbody>
@@ -75,21 +113,42 @@ else
@code {
private List<StoreView>? _rows;
protected override void OnInitialized()
private enum StoreKind { Own, Trusted, Issuer, Rejected }
private sealed record StoreView(string Label, StoreKind Kind, string Path, List<X509Certificate2> Certificates);
private (StoreKind Kind, string Thumbprint, string Subject, string Verb)? _pending;
private string? _statusMsg;
private bool _statusError;
protected override void OnInitialized() => LoadAll();
private void LoadAll()
{
DisposeRows();
var pkiRoot = Config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
_rows = new()
{
LoadStore("Own", Path.Combine(pkiRoot, "own", "certs")),
LoadStore("Trusted peers", Path.Combine(pkiRoot, "trusted", "certs")),
LoadStore("Trusted issuers", Path.Combine(pkiRoot, "issuer", "certs")),
LoadStore("Rejected", Path.Combine(pkiRoot, "rejected", "certs")),
LoadStore("Own", StoreKind.Own, Path.Combine(pkiRoot, "own", "certs")),
LoadStore("Trusted peers", StoreKind.Trusted, Path.Combine(pkiRoot, "trusted", "certs")),
LoadStore("Trusted issuers", StoreKind.Issuer, Path.Combine(pkiRoot, "issuer", "certs")),
LoadStore("Rejected", StoreKind.Rejected, Path.Combine(pkiRoot, "rejected", "certs")),
};
_pending = null;
}
private static StoreView LoadStore(string label, string path)
private void DisposeRows()
{
var view = new StoreView(label, path, new List<X509Certificate2>());
if (_rows is null) return;
foreach (var store in _rows)
foreach (var c in store.Certificates)
c.Dispose();
}
public void Dispose() => DisposeRows();
private static StoreView LoadStore(string label, StoreKind kind, string path)
{
var view = new StoreView(label, kind, path, new List<X509Certificate2>());
if (!Directory.Exists(path)) return view;
foreach (var file in Directory.EnumerateFiles(path).Where(IsCertFile))
{
@@ -107,5 +166,28 @@ else
|| ext.Equals(".crt", StringComparison.OrdinalIgnoreCase);
}
private sealed record StoreView(string Label, string Path, List<X509Certificate2> Certificates);
private void RequestAction(StoreKind kind, X509Certificate2 cert, string verb)
{
_pending = (kind, cert.Thumbprint!, cert.Subject, verb);
_statusMsg = null;
}
private void CancelAction() => _pending = null;
private void ConfirmAction()
{
if (_pending is not { } p) return;
var result = p.Verb switch
{
"trust" => CertManager.Trust(p.Thumbprint),
"untrust" => CertManager.Untrust(p.Thumbprint),
"delete" => CertManager.Delete(p.Kind == StoreKind.Trusted ? "trusted" : "rejected", p.Thumbprint),
_ => CertActionResult.Fail("unknown action"),
};
_statusError = !result.Success;
_statusMsg = result.Success
? $"{char.ToUpper(p.Verb[0])}{p.Verb[1..]} of {p.Subject} succeeded."
: $"{p.Verb} failed: {result.Error}";
LoadAll(); // clears _pending
}
}
@@ -51,6 +51,9 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
services.AddScoped<ScriptAnalysis.IScriptTagCatalog, ScriptAnalysis.ScriptTagCatalog>();
// Certificate-store actions (trust/untrust/delete) for the /certificates page.
services.AddSingleton<Certificates.CertificateStoreManager>();
return services;
}
}