diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
index a0287807..66d4fe3f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
@@ -170,6 +170,12 @@
{
_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)
{
@@ -182,13 +188,16 @@
if (_editingKey != null)
{
// Edit: name is fixed; only the method-scope set is mutable.
- await ApiKeyAdmin.SetMethodsAsync(_editingKey.KeyId, _selectedMethodNames.ToList());
+ 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
{
- if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
-
var created = await ApiKeyAdmin.CreateAsync(_formName.Trim(), _selectedMethodNames.ToList());
_newlyCreatedKeyId = created.KeyId;
_newlyCreatedToken = created.Token; // shown once; never persisted client-side.
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor
index b06700de..a0adf48a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor
@@ -146,7 +146,13 @@
{
var newEnabled = !key.Enabled;
// The seam persists; there is no separate SaveChangesAsync.
- await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
+ 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();
}
@@ -166,7 +172,13 @@
try
{
- await ApiKeyAdmin.DeleteAsync(key.KeyId);
+ 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();
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor
index 1d3f0d9f..67067d8e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor
@@ -331,9 +331,21 @@
return false;
}
- foreach (var update in plan.Updates)
+ try
{
- await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
+ foreach (var update in plan.Updates)
+ {
+ var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
+ if (!ok)
+ throw new InvalidOperationException(
+ $"Key '{NameFor(update.KeyId)}' was not found in the key store.");
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ $"Method '{methodName}' was saved, but updating approved-key scopes failed partway: {ex.Message} " +
+ "Some keys may be partially updated — review them on the API Keys page and retry.", ex);
}
// Selection is now the baseline (matters if save is retried without reload).
@@ -341,6 +353,10 @@
return true;
}
+ // Returns the display name for a keyId if available from the loaded key list, else the id itself.
+ private string NameFor(string keyId) =>
+ _allKeys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))?.Name ?? keyId;
+
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs
index 929acddf..7459ea00 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs
@@ -115,4 +115,28 @@ public sealed class ApiMethodKeyScopeReconcilerTests
Assert.Empty(result.Updates);
Assert.Empty(result.EmptyScopeKeyNames);
}
+
+ ///
+ /// Concurrent-edit guard: if selected == initial (no diff), the reconciler must produce
+ /// ZERO updates even when the live store shows different scopes on that key. The reconciler
+ /// only acts on keys that appear in the diff (added or removed relative to initialKeyIds)
+ /// — it must never touch keys that are not in the diff, regardless of what their current
+ /// live scopes look like.
+ ///
+ [Fact]
+ public void NoDiff_ProducesNoUpdates_EvenWhenLiveScopesDiffer()
+ {
+ // k1 was approved at load time and is still approved — no diff.
+ // However, a concurrent edit changed k1's live scopes to include an extra method.
+ var result = ApiMethodKeyScopeReconciler.Reconcile(
+ methodName: "PlaceOrder",
+ selectedKeyIds: new HashSet { "k1" },
+ initialKeyIds: new HashSet { "k1" },
+ currentMethodsByKey: Current(("k1", new[] { "PlaceOrder", "OtherMethod" })),
+ keyNamesById: Names(("k1", "Key One")));
+
+ // k1 is not in the diff → reconciler must not touch it.
+ Assert.Empty(result.Updates);
+ Assert.Empty(result.EmptyScopeKeyNames);
+ }
}