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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user