235 lines
8.7 KiB
Plaintext
235 lines
8.7 KiB
Plaintext
@page "/admin/api-keys/create"
|
|
@page "/admin/api-keys/{KeyId}/edit"
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
|
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
|
@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">← 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 && _newlyCreatedToken != null)
|
|
{
|
|
<div class="alert alert-success">
|
|
<strong>New API Key Created</strong>
|
|
<div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
|
|
<div class="d-flex align-items-center mt-2">
|
|
<code class="me-2" data-test="created-token">@_newlyCreatedToken</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 token now — it will not be shown again.</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>
|
|
@* Name is fixed on edit — the seam has no rename. *@
|
|
<input type="text" class="form-control form-control-sm" @bind="_formName" disabled="@IsEditMode" />
|
|
</div>
|
|
<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="@_selectedMethodNames.Contains(method.Name)"
|
|
@onchange="e => ToggleMethod(method.Name, (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. At least one is required.
|
|
</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 {
|
|
// Inbound-API key re-arch (C3): this form drives the IInboundApiKeyAdmin seam.
|
|
// Keys are identified by an opaque string KeyId; method access is a set of method
|
|
// NAMES (scopes) carried on the key, replacing the old ApiMethod.ApprovedApiKeyIds CSV.
|
|
// The list of all methods still comes from IInboundApiRepository (methods stay in SQL).
|
|
[Parameter] public string? KeyId { get; set; }
|
|
|
|
private bool IsEditMode => _editingKey != null;
|
|
|
|
private InboundApiKeyInfo? _editingKey;
|
|
private string _formName = string.Empty;
|
|
private string? _formError;
|
|
private string? _errorMessage;
|
|
private string? _newlyCreatedToken;
|
|
private string? _newlyCreatedKeyId;
|
|
private bool _loading = true;
|
|
private bool _saved;
|
|
|
|
private List<ApiMethod> _allMethods = new();
|
|
// Selection set is method NAMES (scopes), not method ids.
|
|
private HashSet<string> _selectedMethodNames = new(StringComparer.Ordinal);
|
|
|
|
private ToastNotification _toast = default!;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
try
|
|
{
|
|
// Methods always come from SQL Server (methods stay on the repository).
|
|
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
|
|
|
if (!string.IsNullOrWhiteSpace(KeyId))
|
|
{
|
|
// No single-key getter on the seam — locate this key in the full list.
|
|
var all = await ApiKeyAdmin.ListAsync();
|
|
_editingKey = all.FirstOrDefault(k => string.Equals(k.KeyId, KeyId, StringComparison.Ordinal));
|
|
if (_editingKey == null)
|
|
{
|
|
_errorMessage = $"API key '{KeyId}' not found.";
|
|
}
|
|
else
|
|
{
|
|
_formName = _editingKey.Name;
|
|
var methods = await ApiKeyAdmin.GetMethodsForKeyAsync(KeyId);
|
|
_selectedMethodNames = new HashSet<string>(methods, StringComparer.Ordinal);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"Failed to load API key: {ex.Message}";
|
|
}
|
|
_loading = false;
|
|
}
|
|
|
|
private async Task SaveKey()
|
|
{
|
|
_formError = null;
|
|
|
|
if (!IsEditMode && string.IsNullOrWhiteSpace(_formName))
|
|
{
|
|
_formError = "Name is required.";
|
|
return;
|
|
}
|
|
|
|
// The seam/server reject empty scope sets; validate in the UI for a clear message.
|
|
if (_selectedMethodNames.Count == 0)
|
|
{
|
|
_formError = "Select at least one API method for this key.";
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_editingKey != null)
|
|
{
|
|
// Edit: name is fixed; only the method-scope set is mutable.
|
|
var ok = await ApiKeyAdmin.SetMethodsAsync(_editingKey.KeyId, _selectedMethodNames.ToList());
|
|
if (!ok)
|
|
{
|
|
_formError = $"API key '{_editingKey.Name}' was not found. Reload and retry.";
|
|
return;
|
|
}
|
|
NavigationManager.NavigateTo("/admin/api-keys");
|
|
}
|
|
else
|
|
{
|
|
var created = await ApiKeyAdmin.CreateAsync(_formName.Trim(), _selectedMethodNames.ToList());
|
|
_newlyCreatedKeyId = created.KeyId;
|
|
_newlyCreatedToken = created.Token; // shown once; never persisted client-side.
|
|
_saved = true;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_formError = $"Save failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
|
|
|
|
private void ToggleMethod(string methodName, bool isChecked)
|
|
{
|
|
if (isChecked) _selectedMethodNames.Add(methodName);
|
|
else _selectedMethodNames.Remove(methodName);
|
|
}
|
|
|
|
private async Task CopyKeyToClipboard()
|
|
{
|
|
if (_newlyCreatedToken == null) return;
|
|
try
|
|
{
|
|
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedToken);
|
|
_toast.ShowSuccess("Copied to clipboard.");
|
|
}
|
|
catch
|
|
{
|
|
_toast.ShowError("Copy failed.");
|
|
}
|
|
}
|
|
}
|