Files
ScadaBridge/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
T
Joseph Doherty 38fc9b4102 feat(ui): drill-ins from detail pages to Audit Log (#23 M7)
Adds "Recent audit activity" deep links from four edit/detail pages into
the central Audit Log, each with a pre-filter encoded in the query string
that the Audit Log page (Bundle D0) now parses on initialization:

  - External Systems (Design/ExternalSystemForm)      → ?target={Name}
  - API Keys         (Admin/ApiKeyForm)                → ?actor={Name}&channel=ApiInbound
  - Sites            (Admin/SiteForm)                  → ?site={SiteIdentifier}
  - Instances        (Deployment/InstanceConfigure)    → ?instance={UniqueName}

The link is suppressed on create/new flows where there is nothing to
drill into yet. Instance is UI-only on the filter bar (the repository
filter contract has no instance column), so the page-side prefill threads
through the InitialInstanceSearch seam on AuditFilterBar.

Site Calls (#22 M7-T11) drill-in is DEFERRED: the Central UI does not
yet host a Site Calls listing page, per M3 reality notes. Add the
drill-in when that page lands.

#23 M7-T12
2026-05-20 20:26:28 -04:00

248 lines
8.9 KiB
Plaintext

@page "/admin/api-keys/create"
@page "/admin/api-keys/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2"
aria-label="Back to API Keys">&larr; Back</a>
<span class="text-muted me-2">·</span>
<h4 class="mb-0">
@if (_saved)
{
@:API Key Created
}
else if (IsEditMode)
{
@:Edit API Key
}
else
{
@:Add API Key
}
</h4>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
pre-filtered to this API key's inbound calls. Inbound audit rows record
the key Name as Actor and live on the ApiInbound channel. *@
@if (IsEditMode && !string.IsNullOrWhiteSpace(_formName))
{
<a class="btn btn-outline-secondary btn-sm ms-auto"
href="/audit/log?actor=@Uri.EscapeDataString(_formName)&channel=ApiInbound"
data-test="audit-link">
Recent audit activity
</a>
}
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_saved && _newlyCreatedKeyValue != null)
{
<div class="alert alert-success">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
</div>
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
@if (IsEditMode)
{
<div class="mb-2">
<label class="form-label small">API Method Access</label>
@if (_allMethods.Count == 0)
{
<div class="form-text">
No API methods configured.
<a href="/design/external-systems">Create one</a> to grant access.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var method in _allMethods.OrderBy(m => m.Name))
{
var checkboxId = $"method-access-{method.Id}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedMethodIds.Contains(method.Id)"
@onchange="e => ToggleMethod(method.Id, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">@method.Name</label>
</div>
}
</div>
<div class="form-text">
Callers using this key can invoke any checked method.
</div>
}
</div>
}
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => _editingKey != null;
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _errorMessage;
private string? _newlyCreatedKeyValue;
private bool _loading = true;
private bool _saved;
private List<ApiMethod> _allMethods = new();
private HashSet<int> _initialMethodIds = new();
private HashSet<int> _selectedMethodIds = new();
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
try
{
if (Id.HasValue)
{
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
if (_editingKey == null)
{
_errorMessage = $"API key with ID {Id.Value} not found.";
}
else
{
_formName = _editingKey.Name;
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
_initialMethodIds = _allMethods
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
.Select(m => m.Id)
.ToHashSet();
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
}
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load API key: {ex.Message}";
}
_loading = false;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
var changedIds = _selectedMethodIds
.Except(_initialMethodIds)
.Concat(_initialMethodIds.Except(_selectedMethodIds))
.ToHashSet();
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
{
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds);
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id);
else ids.Remove(_editingKey.Id);
method.ApprovedApiKeyIds = ids.Count == 0
? null
: string.Join(",", ids.OrderBy(x => x));
await InboundApiRepository.UpdateApiMethodAsync(method);
}
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/api-keys");
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
await InboundApiRepository.AddApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_newlyCreatedKeyValue = keyValue;
_saved = true;
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
private void ToggleMethod(int methodId, bool isChecked)
{
if (isChecked) _selectedMethodIds.Add(methodId);
else _selectedMethodIds.Remove(methodId);
}
private static HashSet<int> ParseApprovedKeyIds(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return new HashSet<int>();
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
.Where(id => id > 0)
.ToHashSet();
}
private async Task CopyKeyToClipboard()
{
if (_newlyCreatedKeyValue == null) return;
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
{
_toast.ShowError("Copy failed.");
}
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}