feat(ui/api-methods): pick approved API keys when editing a method

The ApiMethod entity had an ApprovedApiKeyIds column and ApiKeyValidator
read it, but no UI/CLI/seed code ever wrote to it. Result: any inbound
POST /api/{method} was rejected with 403 "API key not approved for this
method" regardless of which key was sent.

Add an "Approved API Keys" subsection to the method form, between
Timeout and Parameters: vertical list of checkboxes, one per ApiKey
row (with a "Disabled" badge for disabled keys, and a link to
/admin/api-keys when none exist). OnInitializedAsync loads all keys and
parses the existing comma-separated IDs; Save() serializes the selected
set back to the entity on both create and edit paths.

Re-uses IInboundApiRepository.GetAllApiKeysAsync — no repo or migration
changes needed.
This commit is contained in:
Joseph Doherty
2026-05-13 07:12:44 -04:00
parent 1822e3c76f
commit 1c2dc45803

View File

@@ -28,6 +28,40 @@
<label class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Approved API Keys</label>
@if (_allKeys.Count == 0)
{
<div class="form-text">
No API keys configured.
<a href="/admin/api-keys">Create one</a> to authorize callers for this method.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var key in _allKeys)
{
var checkboxId = $"approved-key-{key.Id}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedKeyIds.Contains(key.Id)"
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">
@key.Name
@if (!key.IsEnabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
</label>
</div>
}
</div>
<div class="form-text">
Callers must present a checked key in the <code>X-API-Key</code> header to invoke this method.
</div>
}
</div>
<div class="mb-3">
<label class="form-label">Parameters</label>
<SchemaBuilder Mode="object"
@@ -77,9 +111,17 @@
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
private ApiMethod? _existing;
private List<ApiKey> _allKeys = new();
private HashSet<int> _selectedKeyIds = new();
protected override async Task OnInitializedAsync()
{
try
{
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
}
catch (Exception ex) { _formError = ex.Message; }
if (Id.HasValue)
{
try
@@ -92,6 +134,7 @@
_timeoutSeconds = _existing.TimeoutSeconds;
_params = _existing.ParameterDefinitions;
_returns = _existing.ReturnDefinition;
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
}
}
catch (Exception ex) { _formError = ex.Message; }
@@ -99,6 +142,25 @@
_loading = false;
}
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 void ToggleKey(int keyId, bool isChecked)
{
if (isChecked) _selectedKeyIds.Add(keyId);
else _selectedKeyIds.Remove(keyId);
}
private string? SerializeApprovedKeyIds() =>
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
private async Task Save()
{
_formError = null;
@@ -110,12 +172,14 @@
try
{
var approvedKeyIds = SerializeApprovedKeyIds();
if (_existing != null)
{
_existing.Script = _script;
_existing.TimeoutSeconds = _timeoutSeconds;
_existing.ParameterDefinitions = _params?.Trim();
_existing.ReturnDefinition = _returns?.Trim();
_existing.ApprovedApiKeyIds = approvedKeyIds;
await InboundApiRepository.UpdateApiMethodAsync(_existing);
}
else
@@ -124,7 +188,8 @@
{
TimeoutSeconds = _timeoutSeconds,
ParameterDefinitions = _params?.Trim(),
ReturnDefinition = _returns?.Trim()
ReturnDefinition = _returns?.Trim(),
ApprovedApiKeyIds = approvedKeyIds
};
await InboundApiRepository.AddApiMethodAsync(m);
}