9e243493fb
Applies the family-wide admin-UI cleanup playbook to the Central UI so the Blazor surfaces stop diverging from the shared kit: buttons are grouped rather than individually sized, long cell values are contained instead of widening tables, and hard-coded colours give way to theme tokens. The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell, which the kit emits as an inline style on the shell root. Being a descendant of <html>, it beat the [data-bs-theme="dark"] override for the entire app, so the dark accent had never rendered. Declaring --accent in site.css :root instead lets both schemes resolve; light is unchanged because the value already matched the kit's light default. Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so that block is deleted here rather than duplicated. Verified byte-identical before removal; the repo now declares no --bs-btn-* anywhere. NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages, SiteCallsReport) were additionally refactored from holding the selected record to holding its id and re-resolving from the current page each render, with the resolve doubling as the visibility gate. A background refresh that drops the row now closes the modal instead of showing a stale snapshot. This is a behaviour change and is called out rather than buried: a full-suite run turned up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack (GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler was disposed between render and click — a window the previous field-held record made structurally impossible. Treat the modal lifecycle here as unreviewed. Build 0/0; suite green apart from that one intermittent failure.
197 lines
7.6 KiB
Plaintext
197 lines
7.6 KiB
Plaintext
@page "/admin/api-keys"
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
|
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
|
@inject NavigationManager NavigationManager
|
|
@inject IDialogService Dialog
|
|
|
|
<div class="container-fluid mt-3">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">API Key Management</h4>
|
|
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/api-keys/create")'>Add API Key</button>
|
|
</div>
|
|
|
|
<ToastNotification @ref="_toast" />
|
|
|
|
@if (_loading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else if (_errorMessage != null)
|
|
{
|
|
<div class="alert alert-danger">@_errorMessage</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="mb-3" style="max-width: 320px;">
|
|
<input class="form-control form-control-sm"
|
|
placeholder="Filter by name…"
|
|
@bind="_search" @bind:event="oninput" />
|
|
</div>
|
|
|
|
@if (_keys.Count == 0)
|
|
{
|
|
<p class="text-muted text-center">No API keys configured.</p>
|
|
}
|
|
else if (!FilteredKeys.Any())
|
|
{
|
|
<p class="text-muted small">No API keys match the filter.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm table-striped table-hover">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
<th>Key ID</th>
|
|
<th>Name</th>
|
|
<th>Methods</th>
|
|
<th style="width: 160px;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var key in FilteredKeys)
|
|
{
|
|
<tr @key="key.KeyId">
|
|
<td><code>@TruncateKeyId(key.KeyId)</code></td>
|
|
<td>
|
|
@* Operator-supplied name: clip it so a long value cannot push the
|
|
actions column off-screen. .cell-clip is display:block, hence the
|
|
inner span; the badge stays outside it so status is never clipped. *@
|
|
<div class="d-flex align-items-center">
|
|
<span class="cell-clip" title="@key.Name">@key.Name</span>
|
|
@if (!key.Enabled)
|
|
{
|
|
<span class="badge bg-secondary ms-1">Disabled</span>
|
|
}
|
|
</div>
|
|
</td>
|
|
<td>@key.Methods.Count</td>
|
|
<td>
|
|
<div class="d-flex gap-1">
|
|
<button class="btn btn-outline-primary btn-sm"
|
|
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit</button>
|
|
<div class="dropdown">
|
|
<button class="btn btn-outline-secondary btn-sm"
|
|
data-bs-toggle="dropdown"
|
|
aria-label="@($"More actions for {key.Name}")">⋮</button>
|
|
<ul class="dropdown-menu dropdown-menu-end">
|
|
<li>
|
|
<button class="dropdown-item"
|
|
@onclick="() => ToggleKey(key)">
|
|
@(key.Enabled ? "Disable" : "Enable")
|
|
</button>
|
|
</li>
|
|
<li><hr class="dropdown-divider" /></li>
|
|
<li>
|
|
<button class="dropdown-item text-danger"
|
|
@onclick="() => DeleteKey(key)">
|
|
Delete
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
// Inbound-API key re-arch: this page reads keys from the IInboundApiKeyAdmin seam
|
|
// (string KeyId, method-scopes) rather than the SQL Server ApiKey entity. The seam has no
|
|
// retrievable hash, so the old masked Key-Hash column is gone; KeyId identifies each row.
|
|
private List<InboundApiKeyInfo> _keys = new();
|
|
private bool _loading = true;
|
|
private string? _errorMessage;
|
|
private string _search = string.Empty;
|
|
|
|
private ToastNotification _toast = default!;
|
|
|
|
private IEnumerable<InboundApiKeyInfo> FilteredKeys =>
|
|
string.IsNullOrWhiteSpace(_search)
|
|
? _keys
|
|
: _keys.Where(k =>
|
|
k.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false);
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await LoadDataAsync();
|
|
}
|
|
|
|
private async Task LoadDataAsync()
|
|
{
|
|
_loading = true;
|
|
_errorMessage = null;
|
|
try
|
|
{
|
|
_keys = (await ApiKeyAdmin.ListAsync()).ToList();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"Failed to load API keys: {ex.Message}";
|
|
}
|
|
_loading = false;
|
|
}
|
|
|
|
// Show a short, recognizable prefix of the opaque KeyId rather than the full 32-char value.
|
|
private static string TruncateKeyId(string keyId)
|
|
{
|
|
if (string.IsNullOrEmpty(keyId)) return keyId;
|
|
return keyId.Length <= 12 ? keyId : keyId[..12] + "…";
|
|
}
|
|
|
|
private async Task ToggleKey(InboundApiKeyInfo key)
|
|
{
|
|
try
|
|
{
|
|
var newEnabled = !key.Enabled;
|
|
// The seam persists; there is no separate SaveChangesAsync.
|
|
var ok = await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
|
|
if (!ok)
|
|
{
|
|
_toast.ShowError($"API key '{key.Name}' was not found — it may have been removed. Refreshing.");
|
|
await LoadDataAsync();
|
|
return;
|
|
}
|
|
_toast.ShowSuccess($"API key '{key.Name}' {(newEnabled ? "enabled" : "disabled")}.");
|
|
await LoadDataAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_toast.ShowError($"Toggle failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task DeleteKey(InboundApiKeyInfo key)
|
|
{
|
|
var confirmed = await Dialog.ConfirmAsync(
|
|
"Delete API Key",
|
|
$"Delete API key '{key.Name}'? This cannot be undone.",
|
|
danger: true);
|
|
if (!confirmed) return;
|
|
|
|
try
|
|
{
|
|
var ok = await ApiKeyAdmin.DeleteAsync(key.KeyId);
|
|
if (!ok)
|
|
{
|
|
_toast.ShowError($"API key '{key.Name}' was not found — it may have been removed. Refreshing.");
|
|
await LoadDataAsync();
|
|
return;
|
|
}
|
|
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
|
|
await LoadDataAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_toast.ShowError($"Delete failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
}
|