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:
78
src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs
Normal file
78
src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API endpoints for login/logout. These run outside Blazor Server (standard HTTP POST).
|
||||
/// On success, sets an HTTP-only cookie containing the JWT, then redirects to dashboard.
|
||||
/// </summary>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost("/auth/login", async (HttpContext context) =>
|
||||
{
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
context.Response.Redirect("/login?error=Username+and+password+are+required.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed.");
|
||||
context.Response.Redirect($"/login?error={errorMsg}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Map LDAP groups to roles
|
||||
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
authResult.DisplayName ?? username,
|
||||
authResult.Username ?? username,
|
||||
roleMappingResult.Roles,
|
||||
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
|
||||
|
||||
// Set HTTP-only cookie with the JWT
|
||||
context.Response.Cookies.Append(
|
||||
CookieAuthenticationStateProvider.AuthCookieName,
|
||||
token,
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = context.Request.IsHttps,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
// Cookie expiry matches JWT idle timeout (30 min default)
|
||||
MaxAge = TimeSpan.FromMinutes(30)
|
||||
});
|
||||
|
||||
context.Response.Redirect("/");
|
||||
});
|
||||
|
||||
endpoints.MapPost("/auth/logout", (HttpContext context) =>
|
||||
{
|
||||
context.Response.Cookies.Delete(CookieAuthenticationStateProvider.AuthCookieName, new CookieOptions
|
||||
{
|
||||
Path = "/"
|
||||
});
|
||||
context.Response.Redirect("/login");
|
||||
});
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Server;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the JWT from an HTTP-only cookie and creates a ClaimsPrincipal for Blazor Server.
|
||||
/// This bridges cookie-based auth (set by the login endpoint) with Blazor's auth state.
|
||||
/// </summary>
|
||||
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
||||
{
|
||||
public const string AuthCookieName = "ScadaLink.Auth";
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly JwtTokenService _jwtTokenService;
|
||||
|
||||
public CookieAuthenticationStateProvider(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
JwtTokenService jwtTokenService)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_jwtTokenService = jwtTokenService;
|
||||
}
|
||||
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
{
|
||||
return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
|
||||
}
|
||||
|
||||
var token = httpContext.Request.Cookies[AuthCookieName];
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
|
||||
}
|
||||
|
||||
var principal = _jwtTokenService.ValidateToken(token);
|
||||
if (principal == null)
|
||||
{
|
||||
return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
|
||||
}
|
||||
|
||||
// Check idle timeout
|
||||
if (_jwtTokenService.IsIdleTimedOut(principal))
|
||||
{
|
||||
return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
|
||||
}
|
||||
|
||||
return Task.FromResult(new AuthenticationState(principal));
|
||||
}
|
||||
}
|
||||
131
src/ScadaLink.CentralUI/Components/App.razor
Normal file
131
src/ScadaLink.CentralUI/Components/App.razor
Normal file
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ScadaLink</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1p0TQGL3BNgcree90f9QM0jB1zDTkM6"
|
||||
crossorigin="anonymous" />
|
||||
<style>
|
||||
.sidebar {
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
min-height: 100vh;
|
||||
background-color: #212529;
|
||||
}
|
||||
.sidebar .nav-link {
|
||||
color: #adb5bd;
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sidebar .nav-link:hover {
|
||||
color: #fff;
|
||||
background-color: #343a40;
|
||||
}
|
||||
.sidebar .nav-link.active {
|
||||
color: #fff;
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
.sidebar .nav-section-header {
|
||||
color: #6c757d;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.sidebar .brand {
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #343a40;
|
||||
}
|
||||
#reconnect-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 9999;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
#reconnect-modal .modal-dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#reconnect-modal .modal-content {
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
<body>
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="typeof(App).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
|
||||
<NotAuthorized>
|
||||
@if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
<RedirectToLogin />
|
||||
}
|
||||
else
|
||||
{
|
||||
<NotAuthorizedView />
|
||||
}
|
||||
</NotAuthorized>
|
||||
<Authorizing>
|
||||
<p class="text-muted p-3">Checking authorization...</p>
|
||||
</Authorizing>
|
||||
</AuthorizeRouteView>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="typeof(MainLayout)">
|
||||
<div class="container mt-5">
|
||||
<h3>Page Not Found</h3>
|
||||
<p class="text-muted">The requested page does not exist.</p>
|
||||
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
|
||||
</div>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
|
||||
<div id="reconnect-modal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">Reconnecting...</span>
|
||||
</div>
|
||||
<h5>Connection Lost</h5>
|
||||
<p class="text-muted mb-0">Attempting to reconnect to the server. Please wait...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="_framework/blazor.server.js"></script>
|
||||
<script>
|
||||
Blazor.defaultReconnectionHandler._reconnectCallback = function (d) {
|
||||
document.getElementById('reconnect-modal').style.display = 'block';
|
||||
};
|
||||
Blazor.defaultReconnectionHandler._reconnectedCallback = function (d) {
|
||||
document.getElementById('reconnect-modal').style.display = 'none';
|
||||
};
|
||||
Blazor.defaultReconnectionHandler._reconnectionFailedCallback = function (d) {
|
||||
document.getElementById('reconnect-modal').querySelector('p').textContent =
|
||||
'Unable to reconnect. Please refresh the page.';
|
||||
};
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||
crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex">
|
||||
<NavMenu />
|
||||
<main class="flex-grow-1 p-3" style="min-height: 100vh; background-color: #f8f9fa;">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
80
src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
Normal file
80
src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
Normal file
@@ -0,0 +1,80 @@
|
||||
@using ScadaLink.Security
|
||||
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="brand">ScadaLink</div>
|
||||
|
||||
<ul class="nav flex-column flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||
</li>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@* Admin section — Admin role only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="adminContext">
|
||||
<li class="nav-section-header">Admin</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="admin/ldap-mappings">LDAP Mappings</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="admin/sites">Sites</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="admin/areas">Areas</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Design section — Design role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="designContext">
|
||||
<li class="nav-section-header">Design</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="design/templates">Templates</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="design/shared-scripts">Shared Scripts</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="design/external-systems">External Systems</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Deployment section — Deployment role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="deploymentContext">
|
||||
<li class="nav-section-header">Deployment</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="deployment/instances">Instances</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="deployment/deployments">Deployments</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="deployment/debug-view">Debug View</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Health — visible to all authenticated users *@
|
||||
<li class="nav-section-header">Monitoring</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="monitoring/health">Health Dashboard</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="border-top border-secondary p-2">
|
||||
<span class="d-block text-light small px-2">@context.User.FindFirst("DisplayName")?.Value</span>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button type="submit" class="btn btn-link btn-sm text-muted text-decoration-none px-2">Sign Out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
@@ -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>
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
33
src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor
Normal file
33
src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
34
src/ScadaLink.CentralUI/Components/Pages/Login.razor
Normal file
34
src/ScadaLink.CentralUI/Components/Pages/Login.razor
Normal 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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="container mt-5">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h5 class="alert-heading">Not Authorized</h5>
|
||||
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
|
||||
</div>
|
||||
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using ScadaLink.CentralUI.Auth;
|
||||
using ScadaLink.CentralUI.Components;
|
||||
|
||||
namespace ScadaLink.CentralUI;
|
||||
|
||||
@@ -6,7 +9,11 @@ public static class EndpointExtensions
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCentralUI(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
endpoints.MapAuthEndpoints();
|
||||
|
||||
endpoints.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.CentralUI.Auth;
|
||||
|
||||
namespace ScadaLink.CentralUI;
|
||||
|
||||
@@ -6,7 +8,14 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCentralUI(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
||||
services.AddCascadingAuthenticationState();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
9
src/ScadaLink.CentralUI/_Imports.razor
Normal file
9
src/ScadaLink.CentralUI/_Imports.razor
Normal file
@@ -0,0 +1,9 @@
|
||||
@using System.Net.Http
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using ScadaLink.CentralUI
|
||||
@using ScadaLink.CentralUI.Components.Layout
|
||||
@using ScadaLink.CentralUI.Components.Shared
|
||||
Reference in New Issue
Block a user