Files
ScadaBridge/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
T
Joseph Doherty 733679a376 feat(ui/api-keys): grant API method access on edit page
Admins can now check/uncheck which API methods this key is approved to
invoke directly on /admin/api-keys/{id}/edit, instead of having to bounce
through the Design role's API method editor. Membership is diffed against
the initial state and applied by mutating ApprovedApiKeyIds on each
affected ApiMethod in the same SaveChangesAsync.
2026-05-13 13:41:13 -04:00

237 lines
8.4 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>
</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];
}
}