Resolve all high-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 16:13:58 -04:00
parent 8bfe65512e
commit 146a13238f
192 changed files with 3160 additions and 753 deletions
@@ -1,3 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@using Microsoft.AspNetCore.Components.Authorization
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@@ -92,7 +94,7 @@
try
{
var actor = await ResolveActorAsync();
var actor = await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
if (IsSite)
{
@@ -1,3 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@page "/admin/api-keys/create"
@page "/admin/api-keys/{KeyId}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -187,6 +189,7 @@
try
{
await MutationAuth.RequireRoleAsync(Roles.Administrator);
if (_editingKey != null)
{
// Edit: name is fixed; only the method-scope set is mutable.
@@ -1,3 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@page "/admin/api-keys"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@@ -151,6 +153,7 @@
{
var newEnabled = !key.Enabled;
// The seam persists; there is no separate SaveChangesAsync.
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
var ok = await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
if (!ok)
{
@@ -177,6 +180,7 @@
try
{
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
var ok = await ApiKeyAdmin.DeleteAsync(key.KeyId);
if (!ok)
{
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/admin/ldap-mappings/create"
@page "/admin/ldap-mappings/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security
@@ -163,19 +166,9 @@
try
{
if (_editingMapping != null)
{
_editingMapping.LdapGroupName = _formGroupName.Trim();
_editingMapping.Role = _formRole;
await SecurityRepository.UpdateMappingAsync(_editingMapping);
}
else
{
var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole);
await SecurityRepository.AddMappingAsync(mapping);
}
await SecurityRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_editingMapping is not null
? new UpdateRoleMappingCommand(_editingMapping.Id, _formGroupName.Trim(), _formRole)
: new CreateRoleMappingCommand(_formGroupName.Trim(), _formRole));
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
catch (Exception ex)
@@ -196,9 +189,7 @@
try
{
var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId };
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new AddScopeRuleCommand(Id!.Value, _scopeRuleSiteId));
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
_scopeRuleSiteId = 0;
}
@@ -219,8 +210,7 @@
try
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
await SecurityRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteScopeRuleCommand(rule.Id));
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList();
}
catch (Exception ex)
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/admin/ldap-mappings"
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security
@@ -143,14 +146,7 @@
{
try
{
// Also delete scope rules for this mapping
var rules = await SecurityRepository.GetScopeRulesForMappingAsync(id);
foreach (var rule in rules)
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
}
await SecurityRepository.DeleteMappingAsync(id);
await SecurityRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteRoleMappingCommand(id));
await LoadDataAsync();
}
catch (Exception ex)
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/admin/sites/create"
@page "/admin/sites/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -158,35 +161,16 @@
try
{
if (_editingSite != null)
if (_editingSite is null && string.IsNullOrWhiteSpace(_formIdentifier))
{
_editingSite.Name = _formName.Trim();
_editingSite.Description = _formDescription?.Trim();
_editingSite.NodeAAddress = _formNodeAAddress?.Trim();
_editingSite.NodeBAddress = _formNodeBAddress?.Trim();
_editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim();
_editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim();
await SiteRepository.UpdateSiteAsync(_editingSite);
_formError = "Identifier is required.";
return;
}
else
{
if (string.IsNullOrWhiteSpace(_formIdentifier))
{
_formError = "Identifier is required.";
return;
}
var site = new Site(_formName.Trim(), _formIdentifier.Trim())
{
Description = _formDescription?.Trim(),
NodeAAddress = _formNodeAAddress?.Trim(),
NodeBAddress = _formNodeBAddress?.Trim(),
GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(),
GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim()
};
await SiteRepository.AddSiteAsync(site);
}
await SiteRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_editingSite is not null
? new UpdateSiteCommand(_editingSite.Id, _formName.Trim(), _formDescription?.Trim(),
_formNodeAAddress?.Trim(), _formNodeBAddress?.Trim(), _formGrpcNodeAAddress?.Trim(), _formGrpcNodeBAddress?.Trim())
: new CreateSiteCommand(_formName.Trim(), _formIdentifier.Trim(), _formDescription?.Trim(),
_formNodeAAddress?.Trim(), _formNodeBAddress?.Trim(), _formGrpcNodeAAddress?.Trim(), _formGrpcNodeBAddress?.Trim()));
CommunicationService.RefreshSiteAddresses();
NavigationManager.NavigateTo("/admin/sites");
}
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@* Reference pattern for list pages: card grid (col-lg-6) + flex header + search filter + kebab dropdown + Bootstrap collapse for noisy detail + @key on iterated cards + "No X match the filter." inline + empty-state CTA. Mirror this when building new list pages. *@
@page "/admin/sites"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -165,7 +168,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
private List<Site> _sites = new();
private Dictionary<int, List<DataConnection>> _siteConnections = new();
@@ -221,8 +224,7 @@
try
{
await SiteRepository.DeleteSiteAsync(site.Id);
await SiteRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteSiteCommand(site.Id));
CommunicationService.RefreshSiteAddresses();
_toast.ShowSuccess($"Site '{site.Name}' deleted.");
await LoadDataAsync();
@@ -1,3 +1,7 @@
@using Microsoft.Extensions.DependencyInjection
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/deployment/instances/{Id:int}/configure"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@@ -5,14 +9,12 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@@ -963,8 +965,14 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private async Task<string> GetCurrentUserAsync()
{
var user = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var current = await TemplateEngineRepository.GetInstanceByIdAsync(Id);
if (current is null || !await SiteScope.IsSiteAllowedAsync(current.SiteId))
throw new UnauthorizedAccessException("The instance is outside your current site scope.");
return user;
}
// ── Bindings ────────────────────────────────────────────
@@ -1243,7 +1251,7 @@
_bindingOverrides.GetValueOrDefault(kv.Key)))
.ToList();
var user = await GetCurrentUserAsync();
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetConnectionBindingsAsync(Id, bindings, currentUser));
if (result.IsSuccess)
{
SnapshotSavedBindingState();
@@ -1323,8 +1331,7 @@
{
if (_existingOverrides.TryGetValue(attrName, out var ovr))
{
await TemplateEngineRepository.DeleteInstanceAttributeOverrideAsync(ovr.Id);
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteInstanceAttributeOverrideCommand(Id, attrName));
_existingOverrides.Remove(attrName);
}
_overrideValues.Remove(attrName);
@@ -1377,7 +1384,7 @@
var failures = new List<string>();
foreach (var (attrName, value) in toSave)
{
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAttributeOverrideAsync(Id, attrName, value, currentUser));
if (result.IsSuccess)
_existingOverrides[attrName] = result.Value!;
else
@@ -1454,7 +1461,7 @@
var failures = new List<string>();
foreach (var (attrName, value) in outcome.Overrides)
{
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAttributeOverrideAsync(Id, attrName, value, currentUser));
if (result.IsSuccess)
{
_existingOverrides[attrName] = result.Value!;
@@ -1622,7 +1629,7 @@
{
if (_existingAlarmOverrides.ContainsKey(alarmName))
{
var del = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
var del = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.DeleteAlarmOverrideAsync(Id, alarmName, currentUser));
if (!del.IsSuccess)
{
_editingError = del.Error;
@@ -1639,8 +1646,8 @@
}
else
{
var result = await InstanceService.SetAlarmOverrideAsync(
Id, alarmName, overrideJson, priority, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAlarmOverrideAsync(
Id, alarmName, overrideJson, priority, currentUser));
if (!result.IsSuccess)
{
_editingError = result.Error;
@@ -1675,7 +1682,7 @@
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.DeleteAlarmOverrideAsync(Id, alarmName, currentUser));
if (result.IsSuccess)
{
_existingAlarmOverrides.Remove(alarmName);
@@ -1718,7 +1725,6 @@
}
await UpsertNativeOverrideCore(sourceName, conn, sref, filt);
await TemplateEngineRepository.SaveChangesAsync();
_toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'.");
}
catch (Exception ex)
@@ -1751,27 +1757,8 @@
/// </summary>
private async Task UpsertNativeOverrideCore(string sourceName, string? conn, string? sref, string? filt)
{
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(sourceName)
{
InstanceId = Id,
ConnectionNameOverride = conn,
SourceReferenceOverride = sref,
ConditionFilterOverride = filt
};
await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
_existingNativeOverrides[sourceName] = ovr;
}
else
{
existing.ConnectionNameOverride = conn;
existing.SourceReferenceOverride = sref;
existing.ConditionFilterOverride = filt;
await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
_existingNativeOverrides[sourceName] = existing;
}
var result = await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverrideCommand(Id, sourceName, conn, sref, filt));
if (result is InstanceNativeAlarmSourceOverride saved) _existingNativeOverrides[sourceName] = saved;
// Keep the inline editor showing what was just persisted.
_nasConnEdit[sourceName] = conn;
@@ -1781,12 +1768,7 @@
private async Task ClearNativeOverrideCore(string sourceName)
{
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
if (existing != null)
{
await TemplateEngineRepository.DeleteInstanceNativeAlarmSourceOverrideAsync(existing.Id);
await TemplateEngineRepository.SaveChangesAsync();
}
await MutationServices.ExecuteConfigurationAsync(new DeleteInstanceNativeAlarmSourceOverrideCommand(Id, sourceName));
_existingNativeOverrides.Remove(sourceName);
_nasConnEdit[sourceName] = null;
_nasRefEdit[sourceName] = null;
@@ -1849,17 +1831,9 @@
return;
}
// Every row was validated up front, so the batch applies as a unit through
// the same repository upsert/clear the inline editor uses.
foreach (var row in outcome.Rows)
{
if (row.IsClear)
await ClearNativeOverrideCore(row.SourceName);
else
await UpsertNativeOverrideCore(
row.SourceName, row.Connection, row.SourceReference, row.Filter);
}
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverridesCommand(Id,
outcome.Rows.Select(row => new NativeAlarmSourceOverrideEntry(row.SourceName, row.Connection, row.SourceReference, row.Filter)).ToList()));
await OnInitializedAsync();
_nasCsvImportSucceeded = true;
_nasCsvImportResult = $"Imported {outcome.Rows.Count} native alarm source override(s).";
@@ -1957,7 +1931,7 @@
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, currentUser));
if (result.IsSuccess)
{
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
@@ -1,3 +1,5 @@
@using Microsoft.Extensions.DependencyInjection
@inject IServiceProvider MutationServices
@page "/deployment/instances/create"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@@ -324,8 +326,8 @@
try
{
user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(null, _createSiteId, (instanceService, currentUser) => instanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, currentUser));
if (result.IsFailure)
{
_formError = result.Error;
@@ -355,7 +357,7 @@
string? bindError = null;
try
{
var bind = await InstanceService.SetConnectionBindingsAsync(id, bindings, user);
var bind = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(id, null, (instanceService, currentUser) => instanceService.SetConnectionBindingsAsync(id, bindings, currentUser));
if (bind.IsFailure) bindError = bind.Error;
}
catch (Exception ex)
@@ -378,7 +380,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
/// <summary>
/// Opens the folder-tree template picker. A pick clears any dangling-id hint and
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/deployment/topology"
@page "/deployment/instances"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -705,8 +708,7 @@
return;
}
var user = await GetCurrentUserAsync();
var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user);
var result = await ExecuteConfigurationAsync<Area>(new UpdateAreaCommand(node.EntityId, _renameBuffer.Trim()));
if (result.IsSuccess)
{
CancelRename();
@@ -765,8 +767,7 @@
if (req.SiteId == 0) { _createAreaError = "Select a site."; return; }
if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; }
var user = await GetCurrentUserAsync();
var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user);
var result = await ExecuteConfigurationAsync<Area>(new CreateAreaCommand(req.SiteId, req.Name, req.ParentAreaId));
if (result.IsSuccess)
{
_showCreateAreaDialog = false;
@@ -800,8 +801,7 @@
private async Task SubmitMoveArea((int AreaId, int? NewParentId) req)
{
_moveAreaError = null;
var user = await GetCurrentUserAsync();
var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user);
var result = await ExecuteConfigurationAsync<Area>(new MoveAreaCommand(req.AreaId, req.NewParentId));
if (result.IsSuccess)
{
_showMoveAreaDialog = false;
@@ -836,8 +836,7 @@
private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req)
{
_moveInstanceError = null;
var user = await GetCurrentUserAsync();
var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user);
var result = await ExecuteConfigurationAsync<Instance>(new SetInstanceAreaCommand(req.InstanceId, req.NewAreaId));
if (result.IsSuccess)
{
_showMoveInstanceDialog = false;
@@ -860,8 +859,7 @@
if (!confirmed) return;
_actionInProgress = true;
var user = await GetCurrentUserAsync();
var result = await AreaService.DeleteAreaAsync(node.EntityId, user);
var result = await ExecuteConfigurationAsync<bool>(new DeleteAreaCommand(node.EntityId));
if (result.IsSuccess)
{
_toast.ShowSuccess($"Area '{node.Label}' deleted.");
@@ -885,7 +883,8 @@
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
@@ -910,7 +909,8 @@
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
@@ -941,7 +941,8 @@
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
@@ -965,7 +966,8 @@
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
@@ -1239,6 +1241,35 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private async Task<string?> GetCurrentUserAsync(int instanceId)
{
var user = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
// Re-fetch outside the circuit tracker: the instance may have moved since rendering.
using var scope = ScopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<ITemplateEngineRepository>();
var instance = await repository.GetInstanceByIdAsync(instanceId);
if (instance is null || !await SiteScope.IsSiteAllowedAsync(instance.SiteId))
{
_toast.ShowError("This instance is no longer available within your permitted sites.");
_actionInProgress = false;
return null;
}
return user;
}
private async Task<ZB.MOM.WW.ScadaBridge.Commons.Types.Result<T>> ExecuteConfigurationAsync<T>(object command)
{
try
{
var value = await MutationServices.ExecuteConfigurationAsync(command);
return ZB.MOM.WW.ScadaBridge.Commons.Types.Result<T>.Success((T)value!);
}
catch (Exception ex)
{
var message = ex is UnauthorizedAccessException
or ZB.MOM.WW.ScadaBridge.ManagementService.ManagementCommandException
? ex.Message : "The configuration change failed. Refresh the page and try again.";
return ZB.MOM.WW.ScadaBridge.Commons.Types.Result<T>.Failure(message);
}
}
}
@@ -1,10 +1,14 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/api-methods/create"
@page "/design/api-methods/{Id:int}/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
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository
@@ -297,33 +301,10 @@
try
{
// Save the ApiMethod entity FIRST so the method Name exists before we reconcile
// key scopes against it. The method entity stays in SQL Server; we leave the
// (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5.
string methodName;
if (_existing != null)
{
_existing.Script = _script;
_existing.TimeoutSeconds = _timeoutSeconds;
_existing.TraceAllCalls = _traceAllCalls;
_existing.ParameterDefinitions = _params?.Trim();
_existing.ReturnDefinition = _returns?.Trim();
await InboundApiRepository.UpdateApiMethodAsync(_existing);
methodName = _existing.Name;
}
else
{
var m = new ApiMethod(_name.Trim(), _script)
{
TimeoutSeconds = _timeoutSeconds,
TraceAllCalls = _traceAllCalls,
ParameterDefinitions = _params?.Trim(),
ReturnDefinition = _returns?.Trim()
};
await InboundApiRepository.AddApiMethodAsync(m);
methodName = m.Name;
}
await InboundApiRepository.SaveChangesAsync();
var methodName = _existing?.Name ?? _name.Trim();
await MutationServices.ExecuteConfigurationAsync(_existing is not null
? new UpdateApiMethodCommand(_existing.Id, _script, _timeoutSeconds, _params?.Trim(), _returns?.Trim(), _traceAllCalls)
: new CreateApiMethodCommand(methodName, _script, _timeoutSeconds, _params?.Trim(), _returns?.Trim(), _traceAllCalls));
// Reconcile per-key method-scopes for the affected keys (added/removed vs. load time).
if (!await ReconcileKeyScopesAsync(methodName)) return;
@@ -371,6 +352,7 @@
{
foreach (var update in plan.Updates)
{
await MutationAuth.RequireRoleAsync(Roles.Designer);
var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
if (!ok)
throw new InvalidOperationException(
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/connections/create"
@page "/design/connections/{Id:int}/edit"
@page "/design/data-connections/create"
@@ -327,26 +330,9 @@
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _protocol;
_editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId)
{
PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson,
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_editingConnection is not null
? new UpdateDataConnectionCommand(_editingConnection.Id, _formName.Trim(), _protocol, primaryJson, backupJson, _showBackup ? _formFailoverRetryCount : 3)
: new CreateDataConnectionCommand(_formSiteId, _formName.Trim(), _protocol, primaryJson, backupJson, _showBackup ? _formFailoverRetryCount : 3));
NavigationManager.NavigateTo("/design/connections");
}
catch (Exception ex)
@@ -1,7 +1,9 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/connections"
@page "/design/data-connections"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@@ -417,8 +419,7 @@
try
{
await SiteRepository.DeleteDataConnectionAsync(conn.Id);
await SiteRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteDataConnectionCommand(conn.Id));
_toast.ShowSuccess($"Connection '{conn.Name}' deleted.");
await LoadDataAsync();
}
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/db-connections/create"
@page "/design/db-connections/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -94,24 +97,9 @@
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.ConnectionString = _connectionString.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_existing);
}
else
{
var dc = new DatabaseConnectionDefinition(_name.Trim(), _connectionString.Trim())
{
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
};
await ExternalSystemRepository.AddDatabaseConnectionAsync(dc);
}
await ExternalSystemRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_existing is not null
? new UpdateDatabaseConnectionDefCommand(_existing.Id, _name.Trim(), _connectionString.Trim(), _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))
: new CreateDatabaseConnectionDefCommand(_name.Trim(), _connectionString.Trim(), _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)));
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/external-systems/create"
@page "/design/external-systems/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -137,29 +140,9 @@
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.EndpointUrl = _endpointUrl.Trim();
_existing.AuthType = _authType;
_existing.AuthConfiguration = _authConfig?.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
_existing.TimeoutSeconds = _timeoutSeconds;
await ExternalSystemRepository.UpdateExternalSystemAsync(_existing);
}
else
{
var es = new ExternalSystemDefinition(_name.Trim(), _endpointUrl.Trim(), _authType)
{
AuthConfiguration = _authConfig?.Trim(),
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds),
TimeoutSeconds = _timeoutSeconds
};
await ExternalSystemRepository.AddExternalSystemAsync(es);
}
await ExternalSystemRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_existing is not null
? new UpdateExternalSystemCommand(_existing.Id, _name.Trim(), _endpointUrl.Trim(), _authType, _authConfig?.Trim() ?? "", _timeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))
: new CreateExternalSystemCommand(_name.Trim(), _endpointUrl.Trim(), _authType, _authConfig?.Trim(), _timeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)));
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/external-systems"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
@@ -189,7 +192,7 @@
private async Task DeleteExtSys(ExternalSystemDefinition es)
{
if (!await Dialog.ConfirmAsync("Delete External System", $"Delete '{es.Name}'?", danger: true)) return;
try { await ExternalSystemRepository.DeleteExternalSystemAsync(es.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
try { await MutationServices.ExecuteConfigurationAsync(new DeleteExternalSystemCommand(es.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -262,7 +265,7 @@
private async Task DeleteDbConn(DatabaseConnectionDefinition dc)
{
if (!await Dialog.ConfirmAsync("Delete DB Connection", $"Delete '{dc.Name}'?", danger: true)) return;
try { await ExternalSystemRepository.DeleteDatabaseConnectionAsync(dc.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
try { await MutationServices.ExecuteConfigurationAsync(new DeleteDatabaseConnectionDefCommand(dc.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -337,7 +340,7 @@
private async Task DeleteApiMethod(ApiMethod m)
{
if (!await Dialog.ConfirmAsync("Delete", $"Delete API method '{m.Name}'?", danger: true)) return;
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
try { await MutationServices.ExecuteConfigurationAsync(new DeleteApiMethodCommand(m.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -207,7 +207,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
protected override async Task OnInitializedAsync()
{
@@ -104,7 +104,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
private List<SharedScript> _scripts = new();
private bool _loading = true;
@@ -122,5 +122,5 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
}
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/templates/{Id:int}"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@@ -217,7 +220,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
private RenderFragment RenderTemplateDetail() => __builder =>
{
@@ -1080,13 +1083,10 @@
{
var existing = await TemplateEngineRepository.GetTemplateNativeAlarmSourceByIdAsync(id);
if (existing == null) return "Source no longer exists.";
existing.ConnectionName = draft.ConnectionName.Trim();
existing.SourceReference = draft.SourceReference.Trim();
existing.ConditionFilter = string.IsNullOrWhiteSpace(draft.ConditionFilter) ? null : draft.ConditionFilter.Trim();
existing.Description = string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim();
existing.IsLocked = draft.IsLocked;
await TemplateEngineRepository.UpdateTemplateNativeAlarmSourceAsync(existing);
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new UpdateTemplateNativeAlarmSourceCommand(id, existing.Name,
draft.ConnectionName.Trim(), draft.SourceReference.Trim(),
string.IsNullOrWhiteSpace(draft.ConditionFilter) ? null : draft.ConditionFilter.Trim(),
string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(), draft.IsLocked));
_toast.ShowSuccess($"Native alarm source '{existing.Name}' updated.");
return null;
}
@@ -1105,8 +1105,8 @@
Description = string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(),
IsLocked = draft.IsLocked
};
await TemplateEngineRepository.AddTemplateNativeAlarmSourceAsync(source);
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new AddTemplateNativeAlarmSourceCommand(source.TemplateId, source.Name,
source.ConnectionName, source.SourceReference, source.ConditionFilter, source.Description, source.IsLocked));
_toast.ShowSuccess($"Native alarm source '{source.Name}' added.");
return null;
}
@@ -1115,8 +1115,7 @@
{
var confirmed = await Dialog.ConfirmAsync("Delete Native Alarm Source", $"Delete native alarm source '{src.Name}'?", danger: true);
if (!confirmed) return;
await TemplateEngineRepository.DeleteTemplateNativeAlarmSourceAsync(src.Id);
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteTemplateNativeAlarmSourceCommand(src.Id));
_toast.ShowSuccess($"Native alarm source '{src.Name}' deleted.");
await LoadAsync();
}
@@ -118,7 +118,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();
@@ -382,7 +382,7 @@ public partial class TransportExport : ComponentBase
_downloadError = null;
try
{
var user = await Auth.GetCurrentUsernameAsync();
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
var sourceEnv = TransportOptions.Value.SourceEnvironment;
if (string.IsNullOrWhiteSpace(sourceEnv))
{
@@ -465,7 +465,7 @@ public partial class TransportImport : ComponentBase, IDisposable
{
try
{
var user = await Auth.GetCurrentUsernameAsync();
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
await AuditService.LogAsync(
user: user,
@@ -857,7 +857,7 @@ public partial class TransportImport : ComponentBase, IDisposable
_result = null;
try
{
var user = await Auth.GetCurrentUsernameAsync();
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
// Fold the operator's Map-step choices into the name map. For
// central-config-only bundles (no required mappings) this is
// BundleNameMap.Empty, which the importer normalises away.
@@ -1,3 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@page "/monitoring/parked-messages"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@@ -647,7 +649,10 @@
try
{
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
if (resp.Success) success++; else failed++;
}
catch { failed++; }
@@ -680,7 +685,10 @@
try
{
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
if (resp.Success) success++; else failed++;
}
catch { failed++; }
@@ -702,6 +710,9 @@
try
{
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
if (resp.Success)
{
@@ -731,6 +742,9 @@
try
{
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
if (resp.Success)
{
@@ -1,7 +1,9 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/notifications/lists/create"
@page "/notifications/lists/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@@ -185,20 +187,9 @@
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
await NotificationRepository.UpdateNotificationListAsync(_existing);
}
else
{
// Type is set at create time and never changes thereafter (the selector
// is disabled on edit) so a list's recipients always carry the matching
// contact field.
var nl = new NotificationList(_name.Trim()) { Type = _type };
await NotificationRepository.AddNotificationListAsync(nl);
}
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_existing is not null
? new RenameNotificationListCommand(_existing.Id, _name.Trim())
: new CreateNotificationListCommand(_name.Trim(), [], _type));
NavigationManager.NavigateTo("/notifications/lists");
}
catch (Exception ex) { _formError = ex.Message; }
@@ -238,8 +229,7 @@
try
{
await NotificationRepository.AddRecipientAsync(r);
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new AddNotificationRecipientCommand(r.NotificationListId, r.Name, r.EmailAddress, r.PhoneNumber));
_recipientName = _recipientEmail = _recipientPhone = string.Empty;
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
@@ -264,8 +254,7 @@
{
try
{
await NotificationRepository.DeleteRecipientAsync(r.Id);
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteNotificationRecipientCommand(r.Id));
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id!.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/notifications/lists"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@@ -165,8 +168,7 @@
}
try
{
await NotificationRepository.DeleteNotificationListAsync(list.Id);
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteNotificationListCommand(list.Id));
_toast.ShowSuccess("Deleted.");
await LoadAsync();
}
@@ -604,7 +604,8 @@
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
if (!await IsRowSiteAllowedAsync(n.SourceSiteId)) throw new UnauthorizedAccessException("Site access was revoked.");
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var response = await CommunicationService.RetryNotificationAsync(
new RetryNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId, requestedBy));
if (response.Success)
@@ -642,7 +643,8 @@
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
if (!await IsRowSiteAllowedAsync(n.SourceSiteId)) throw new UnauthorizedAccessException("Site access was revoked.");
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var response = await CommunicationService.DiscardNotificationAsync(
new DiscardNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId, requestedBy));
if (response.Success)
@@ -848,6 +850,9 @@
return _permittedSiteIds.Contains(resolved.Id);
}
private Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
=> Task.FromResult(IsRowSiteAllowedSync(sourceSiteId));
private async Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
{
var resolved = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == sourceSiteId);
return resolved is not null && await SiteScope.IsSiteAllowedAsync(resolved.Id);
}
}
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/notifications/sms"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@@ -245,48 +248,18 @@
try
{
if (_editingSms != null)
if (_editingSms is null && typedAuthToken is null)
{
_editingSms.AccountSid = _accountSid.Trim();
_editingSms.FromNumber = string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim();
_editingSms.MessagingServiceSid = string.IsNullOrWhiteSpace(_messagingServiceSid)
? null
: _messagingServiceSid.Trim();
_editingSms.ApiBaseUrl = string.IsNullOrWhiteSpace(_apiBaseUrl) ? null : _apiBaseUrl.Trim();
// Preserve-if-blank: only overwrite the stored token when a new value was typed.
if (typedAuthToken != null)
{
_editingSms.AuthToken = typedAuthToken;
}
_editingSms.ConnectionTimeoutSeconds = _connectionTimeoutSeconds;
_editingSms.MaxRetries = _maxRetries;
_editingSms.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await NotificationRepository.UpdateSmsConfigurationAsync(_editingSms);
_formError = "Auth Token is required.";
return;
}
else
{
if (typedAuthToken == null)
{
_formError = "Auth Token is required.";
return;
}
var sms = new SmsConfigurationEntity(
_accountSid.Trim(),
string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim())
{
MessagingServiceSid = string.IsNullOrWhiteSpace(_messagingServiceSid)
? null
: _messagingServiceSid.Trim(),
ApiBaseUrl = string.IsNullOrWhiteSpace(_apiBaseUrl) ? null : _apiBaseUrl.Trim(),
AuthToken = typedAuthToken,
ConnectionTimeoutSeconds = _connectionTimeoutSeconds,
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds),
};
await NotificationRepository.AddSmsConfigurationAsync(sms);
}
await NotificationRepository.SaveChangesAsync();
var from = string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim();
var messaging = string.IsNullOrWhiteSpace(_messagingServiceSid) ? null : _messagingServiceSid.Trim();
await MutationServices.ExecuteConfigurationAsync(_editingSms is not null
? new UpdateSmsConfigCommand(_editingSms.Id, _accountSid.Trim(), from, messaging, _apiBaseUrl?.Trim() ?? "", typedAuthToken,
_connectionTimeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))
: new CreateSmsConfigCommand(_accountSid.Trim(), from, messaging, _apiBaseUrl?.Trim(), typedAuthToken,
_connectionTimeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)));
_showForm = false;
_toast.ShowSuccess("SMS configuration saved.");
await LoadAsync();
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/notifications/smtp"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@@ -345,33 +348,11 @@
try
{
if (_editingSmtp != null)
{
_editingSmtp.Host = _host.Trim();
_editingSmtp.Port = _port;
_editingSmtp.Transport = _transport;
_editingSmtp.AuthType = _authType;
_editingSmtp.TlsMode = _tlsMode;
_editingSmtp.Credentials = _credentials?.Trim();
_editingSmtp.FromAddress = _fromAddress.Trim();
_editingSmtp.OAuth2Authority = NormalizeOptional(_oauth2Authority);
_editingSmtp.OAuth2Scope = NormalizeOptional(_oauth2Scope);
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
}
else
{
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
{
Port = _port,
Transport = _transport,
TlsMode = _tlsMode,
Credentials = _credentials?.Trim(),
OAuth2Authority = NormalizeOptional(_oauth2Authority),
OAuth2Scope = NormalizeOptional(_oauth2Scope)
};
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
}
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_editingSmtp is not null
? new UpdateSmtpConfigCommand(_editingSmtp.Id, _host.Trim(), _port, _authType, _fromAddress.Trim(), _tlsMode,
_credentials?.Trim() ?? "", NormalizeOptional(_oauth2Authority) ?? "", NormalizeOptional(_oauth2Scope) ?? "", _transport)
: new CreateSmtpConfigCommand(_host.Trim(), _port, _authType, _fromAddress.Trim(), _tlsMode,
_credentials?.Trim(), NormalizeOptional(_oauth2Authority), NormalizeOptional(_oauth2Scope), _transport));
_showForm = false;
_toast.ShowSuccess("SMTP configuration saved.");
await LoadAsync();
@@ -338,7 +338,8 @@ public partial class SiteCallsReport
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
if (!await IsRowSiteAllowedAsync(c.SourceSite)) throw new UnauthorizedAccessException("Site access was revoked.");
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var response = await CommunicationService.RetrySiteCallAsync(
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
@@ -374,7 +375,8 @@ public partial class SiteCallsReport
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
if (!await IsRowSiteAllowedAsync(c.SourceSite)) throw new UnauthorizedAccessException("Site access was revoked.");
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var response = await CommunicationService.DiscardSiteCallAsync(
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
@@ -589,16 +591,10 @@ public partial class SiteCallsReport
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
/// user, or when the row's source site maps to a Site.Id in the permitted set.
/// </summary>
private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
private async Task<bool> IsRowSiteAllowedAsync(string sourceSite)
{
if (_siteScopeSystemWide)
return Task.FromResult(true);
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
if (resolved is null)
return Task.FromResult(true);
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
var resolved = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == sourceSite);
return resolved is not null && await SiteScope.IsSiteAllowedAsync(resolved.Id);
}
// ── Trends ──────────────────────────────────────────────────────────────
@@ -2,28 +2,11 @@
@inject NavigationManager Navigation
@inject IJSRuntime JS
<AntiforgeryToken />
@code {
// CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by
// the cookie authentication middleware (ZB.MOM.WW.ScadaBridge.Security AddCookie:
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
// cookie is continually renewed; an idle user's cookie lapses after the idle
// timeout. There is no fixed login-time deadline to redirect at.
//
// This component must NOT poll the Blazor AuthenticationStateProvider:
// CookieAuthenticationStateProvider serves a frozen constructor-time
// principal for the whole circuit (CentralUI-004), so the polled auth state
// can never transition to "expired" and the redirect would never fire
// (CentralUI-020).
//
// Instead it polls the server endpoint GET /auth/ping via fetch(). Being a
// normal HTTP request, the cookie middleware re-validates — and slides — the
// cookie on every hit, and answers 200 while the session is live or 401 once
// it has lapsed. A genuine idle user's circuit produces no other HTTP
// traffic, so once the cookie lapses the next ping returns 401 and this
// component redirects to /login. (The ping itself slides the cookie, but the
// poll interval is well under the idle timeout, so an idle session still
// lapses on schedule once the poll catches the lapsed state — the ping only
// ever observes expiry, it does not keep a dead session alive.)
// Passive HTTP probes observe expiry but never advance activity. The JS module
// separately reports trusted browser input through an antiforgery-protected POST.
/// <summary>Server endpoint that reports live session validity.</summary>
internal const string PingUrl = "/auth/ping";
@@ -49,6 +32,14 @@
_ = PollSessionAsync(_cts.Token);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || IsOnLoginPage) return;
try { _module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath); }
catch (JSDisconnectedException) { }
catch (JSException) { }
}
private bool IsOnLoginPage =>
Navigation.ToBaseRelativePath(Navigation.Uri)
.StartsWith("login", StringComparison.OrdinalIgnoreCase);