Phase 1 WP-11–22: Host infrastructure, Blazor Server UI, and integration tests

Host infrastructure (WP-11–17):
- StartupValidator with 19 validation rules
- /health/ready endpoint with DB + Akka health checks
- Akka.NET bootstrap via AkkaHostedService (HOCON config, cluster, remoting, SBR)
- Serilog with SiteId/NodeHostname/NodeRole enrichment
- DeadLetterMonitorActor with count tracking
- CoordinatedShutdown wiring (no Environment.Exit)
- Windows Service support (UseWindowsService)

Central UI (WP-18–21):
- Blazor Server shell with Bootstrap 5, role-aware NavMenu
- Login/logout flow (LDAP auth → JWT → HTTP-only cookie)
- CookieAuthenticationStateProvider with idle timeout
- LDAP group mapping CRUD page (Admin role)
- Route guards with Authorize attributes per role
- SignalR reconnection overlay for failover

Integration tests (WP-22):
- Startup validation, auth flow, audit transactions, readiness gating
186 tests pass (1 skipped: LDAP integration), zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 19:50:59 -04:00
parent cafb7d2006
commit d38356efdb
47 changed files with 2436 additions and 71 deletions

View File

@@ -0,0 +1,8 @@
@page "/admin/areas"
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
<div class="container mt-4">
<h4>Areas</h4>
<p class="text-muted">Area management will be available in a future phase.</p>
</div>

View File

@@ -0,0 +1,313 @@
@page "/admin/ldap-mappings"
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@using ScadaLink.Commons.Entities.Security
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Security
@inject ISecurityRepository SecurityRepository
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">LDAP Group Mappings</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Mapping</button>
</div>
@if (_loading)
{
<p class="text-muted">Loading...</p>
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@* Add / Edit form *@
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingMapping == null ? "Add New Mapping" : "Edit Mapping")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">LDAP Group Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
</div>
<div class="col-md-3">
<label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option>
<option value="Admin">Admin</option>
<option value="Design">Design</option>
<option value="Deployment">Deployment</option>
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@* Mappings table *@
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>LDAP Group Name</th>
<th>Role</th>
<th>Site Scope Rules</th>
<th style="width: 200px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_mappings.Count == 0)
{
<tr>
<td colspan="5" class="text-muted text-center">No mappings configured.</td>
</tr>
}
@foreach (var mapping in _mappings)
{
<tr>
<td>@mapping.Id</td>
<td>@mapping.LdapGroupName</td>
<td><span class="badge bg-secondary">@mapping.Role</span></td>
<td>
@{
var rules = _scopeRules.GetValueOrDefault(mapping.Id);
}
@if (rules != null && rules.Count > 0)
{
@foreach (var rule in rules)
{
<span class="badge bg-info text-dark me-1">Site @rule.SiteId</span>
}
}
else
{
<span class="text-muted small">All sites</span>
}
@if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
{
<button class="btn btn-outline-info btn-sm ms-2 py-0 px-1"
@onclick="() => ShowScopeRuleForm(mapping.Id)">+ Scope</button>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditMapping(mapping)">Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteMapping(mapping.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
@* Scope rule form *@
@if (_showScopeRuleForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Add Site Scope Rule (Mapping #@_scopeRuleMappingId)</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Site ID</label>
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveScopeRule">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScopeRuleForm">Cancel</button>
</div>
</div>
@if (_scopeRuleError != null)
{
<div class="text-danger small mt-1">@_scopeRuleError</div>
}
</div>
</div>
}
}
</div>
@code {
private List<LdapGroupMapping> _mappings = new();
private Dictionary<int, List<SiteScopeRule>> _scopeRules = new();
private bool _loading = true;
private string? _errorMessage;
// Mapping form state
private bool _showForm;
private LdapGroupMapping? _editingMapping;
private string _formGroupName = string.Empty;
private string _formRole = string.Empty;
private string? _formError;
// Scope rule form state
private bool _showScopeRuleForm;
private int _scopeRuleMappingId;
private int _scopeRuleSiteId;
private string? _scopeRuleError;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_mappings = (await SecurityRepository.GetAllMappingsAsync()).ToList();
_scopeRules.Clear();
foreach (var mapping in _mappings)
{
var rules = await SecurityRepository.GetScopeRulesForMappingAsync(mapping.Id);
if (rules.Count > 0)
{
_scopeRules[mapping.Id] = rules.ToList();
}
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load mappings: {ex.Message}";
}
_loading = false;
}
private void ShowAddForm()
{
_editingMapping = null;
_formGroupName = string.Empty;
_formRole = string.Empty;
_formError = null;
_showForm = true;
}
private void EditMapping(LdapGroupMapping mapping)
{
_editingMapping = mapping;
_formGroupName = mapping.LdapGroupName;
_formRole = mapping.Role;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingMapping = null;
_formError = null;
}
private async Task SaveMapping()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formGroupName))
{
_formError = "LDAP Group Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_formRole))
{
_formError = "Role is required.";
return;
}
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();
_showForm = false;
_editingMapping = null;
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteMapping(int id)
{
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 LoadDataAsync();
}
catch (Exception ex)
{
_errorMessage = $"Delete failed: {ex.Message}";
}
}
private void ShowScopeRuleForm(int mappingId)
{
_scopeRuleMappingId = mappingId;
_scopeRuleSiteId = 0;
_scopeRuleError = null;
_showScopeRuleForm = true;
}
private void CancelScopeRuleForm()
{
_showScopeRuleForm = false;
_scopeRuleError = null;
}
private async Task SaveScopeRule()
{
_scopeRuleError = null;
if (_scopeRuleSiteId <= 0)
{
_scopeRuleError = "Site ID must be a positive number.";
return;
}
try
{
var rule = new SiteScopeRule
{
LdapGroupMappingId = _scopeRuleMappingId,
SiteId = _scopeRuleSiteId
};
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
_showScopeRuleForm = false;
await LoadDataAsync();
}
catch (Exception ex)
{
_scopeRuleError = $"Save failed: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,8 @@
@page "/admin/sites"
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
<div class="container mt-4">
<h4>Sites</h4>
<p class="text-muted">Site management will be available in a future phase.</p>
</div>

View File

@@ -0,0 +1,33 @@
@page "/"
@attribute [Authorize]
<div class="container mt-4">
<h3>Welcome to ScadaLink</h3>
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
<AuthorizeView>
<Authorized>
<div class="card mt-3" style="max-width: 500px;">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Signed in as</h6>
<p class="card-text mb-1"><strong>@context.User.FindFirst("DisplayName")?.Value</strong></p>
<p class="card-text small text-muted mb-2">@context.User.FindFirst("Username")?.Value</p>
@{
var roles = context.User.FindAll("Role").Select(c => c.Value).ToList();
}
@if (roles.Count > 0)
{
<h6 class="card-subtitle mb-1 mt-3 text-muted">Roles</h6>
<div>
@foreach (var role in roles)
{
<span class="badge bg-secondary me-1">@role</span>
}
</div>
}
</div>
</div>
</Authorized>
</AuthorizeView>
</div>

View File

@@ -0,0 +1,11 @@
@page "/deployment/debug-view"
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
<div class="container mt-4">
<h4>Debug View</h4>
<p class="text-muted">Real-time debug view will be available in a future phase.</p>
<div class="alert alert-info" role="alert">
<strong>Note:</strong> Debug view streams are lost on failover. If the connection drops, you will need to re-open the debug view.
</div>
</div>

View File

@@ -0,0 +1,8 @@
@page "/deployment/deployments"
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
<div class="container mt-4">
<h4>Deployments</h4>
<p class="text-muted">Deployment management will be available in a future phase.</p>
</div>

View File

@@ -0,0 +1,8 @@
@page "/deployment/instances"
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
<div class="container mt-4">
<h4>Instances</h4>
<p class="text-muted">Instance management will be available in a future phase.</p>
</div>

View File

@@ -0,0 +1,8 @@
@page "/design/external-systems"
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
<div class="container mt-4">
<h4>External Systems</h4>
<p class="text-muted">External system management will be available in a future phase.</p>
</div>

View File

@@ -0,0 +1,8 @@
@page "/design/shared-scripts"
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
<div class="container mt-4">
<h4>Shared Scripts</h4>
<p class="text-muted">Shared script management will be available in a future phase.</p>
</div>

View File

@@ -0,0 +1,8 @@
@page "/design/templates"
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
<div class="container mt-4">
<h4>Templates</h4>
<p class="text-muted">Template management will be available in a future phase.</p>
</div>

View File

@@ -0,0 +1,34 @@
@page "/login"
<div class="container" style="max-width: 400px; margin-top: 10vh;">
<div class="card shadow-sm">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">ScadaLink</h4>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
}
<form method="post" action="/auth/login">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username"
required autocomplete="username" autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
required autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
</div>
</div>
<p class="text-center text-muted mt-3 small">Authenticate with your organization's LDAP credentials.</p>
</div>
@code {
[SupplyParameterFromQuery(Name = "error")]
public string? ErrorMessage { get; set; }
}

View File

@@ -0,0 +1,7 @@
@page "/monitoring/health"
@attribute [Authorize]
<div class="container mt-4">
<h4>Health Dashboard</h4>
<p class="text-muted">Site health monitoring will be available in a future phase.</p>
</div>