feat: separate create/edit form pages, Playwright test infrastructure, /auth/token endpoint

Move all CRUD create/edit forms from inline on list pages to dedicated form pages
with back-button navigation and post-save redirect. Add Playwright Docker container
(browser server on port 3000) with 25 passing E2E tests covering login, navigation,
and site CRUD workflows. Add POST /auth/token endpoint for clean JWT retrieval.
This commit is contained in:
Joseph Doherty
2026-03-21 15:17:24 -04:00
parent b3f8850711
commit d3194e3634
31 changed files with 2333 additions and 1117 deletions
@@ -0,0 +1,144 @@
@page "/admin/api-keys/create"
@page "/admin/api-keys/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
@if (_saved)
{
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">&larr; Back to API Keys</a>
}
else
{
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">&larr; Back</a>
}
<h4 class="mb-0">@(_saved ? "API Key Created" : (_editingKey != null ? "Edit API Key" : "Add API Key"))</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_saved && _newlyCreatedKeyValue != null)
{
<div class="alert alert-success">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
</div>
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _errorMessage;
private string? _newlyCreatedKeyValue;
private bool _loading = true;
private bool _saved;
protected override async Task OnInitializedAsync()
{
try
{
if (Id.HasValue)
{
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
if (_editingKey == null)
{
_errorMessage = $"API key with ID {Id.Value} not found.";
}
else
{
_formName = _editingKey.Name;
}
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load API key: {ex.Message}";
}
_loading = false;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/api-keys");
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
await InboundApiRepository.AddApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_newlyCreatedKeyValue = keyValue;
_saved = true;
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
private void CopyKeyToClipboard()
{
// Note: JS interop for clipboard would be needed for actual copy.
// For now the key is displayed for manual copy.
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}
@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">API Key Management</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add API Key</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/api-keys/create")'>Add API Key</button>
</div>
<ToastNotification @ref="_toast" />
@@ -24,42 +25,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingKey == null ? "Add New API Key" : "Edit API Key")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="col-md-4">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">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>
}
@if (_newlyCreatedKeyValue != null)
{
<div class="alert alert-success alert-dismissible fade show">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
<button type="button" class="btn-close" @onclick="() => _newlyCreatedKeyValue = null"></button>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@@ -95,7 +60,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditKey(key)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
@if (key.IsEnabled)
{
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@@ -121,12 +86,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _newlyCreatedKeyValue;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -156,63 +115,6 @@
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
}
private void ShowAddForm()
{
_editingKey = null;
_formName = string.Empty;
_formError = null;
_showForm = true;
}
private void EditKey(ApiKey key)
{
_editingKey = key;
_formName = key.Name;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingKey = null;
_formError = null;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue)
{
IsEnabled = true
};
await InboundApiRepository.AddApiKeyAsync(key);
_newlyCreatedKeyValue = keyValue;
}
await InboundApiRepository.SaveChangesAsync();
_showForm = false;
_editingKey = null;
_toast.ShowSuccess("API key saved.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task ToggleKey(ApiKey key)
{
try
@@ -247,18 +149,4 @@
}
}
private void CopyKeyToClipboard()
{
// Note: JS interop for clipboard would be needed for actual copy.
// For now the key is displayed for manual copy.
_toast.ShowInfo("Key displayed above. Select and copy manually.");
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}
@@ -0,0 +1,123 @@
@page "/admin/data-connections/create"
@page "/admin/data-connections/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-2">
<label class="form-label small">Protocol</label>
<select class="form-select form-select-sm" @bind="_formProtocol">
<option value="">Select...</option>
<option value="OpcUa">OPC UA</option>
<option value="LmxProxy">LMX Proxy</option>
<option value="Custom">Custom</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small">Configuration (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private DataConnection? _editingConnection;
private string _formName = string.Empty;
private string _formProtocol = string.Empty;
private string? _formConfiguration;
private string? _formError;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null)
{
_formName = _editingConnection.Name;
_formProtocol = _editingConnection.Protocol;
_formConfiguration = _editingConnection.Configuration;
}
}
catch (Exception ex)
{
_formError = $"Failed to load connection: {ex.Message}";
}
}
_loading = false;
}
private async Task SaveConnection()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _formProtocol;
_editingConnection.Configuration = _formConfiguration?.Trim();
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _formProtocol)
{
Configuration = _formConfiguration?.Trim()
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/data-connections");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/data-connections");
}
}
@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Data Connections</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Connection</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/data-connections/create")'>Add Connection</button>
</div>
<ToastNotification @ref="_toast" />
@@ -24,43 +25,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingConnection == null ? "Add New Connection" : "Edit Connection")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="col-md-2">
<label class="form-label small">Protocol</label>
<select class="form-select form-select-sm" @bind="_formProtocol">
<option value="">Select...</option>
<option value="OpcUa">OPC UA</option>
<option value="LmxProxy">LMX Proxy</option>
<option value="Custom">Custom</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Configuration (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">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>
}
@* Assignment form *@
@if (_showAssignForm)
{
@@ -154,7 +118,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditConnection(conn)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteConnection(conn)">Delete</button>
</td>
@@ -172,13 +136,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private DataConnection? _editingConnection;
private string _formName = string.Empty;
private string _formProtocol = string.Empty;
private string? _formConfiguration;
private string? _formError;
private bool _showAssignForm;
private int _assignConnectionId;
private int _assignSiteId;
@@ -224,67 +181,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingConnection = null;
_formName = string.Empty;
_formProtocol = string.Empty;
_formConfiguration = null;
_formError = null;
_showForm = true;
}
private void EditConnection(DataConnection conn)
{
_editingConnection = conn;
_formName = conn.Name;
_formProtocol = conn.Protocol;
_formConfiguration = conn.Configuration;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingConnection = null;
_formError = null;
}
private async Task SaveConnection()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _formProtocol;
_editingConnection.Configuration = _formConfiguration?.Trim();
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _formProtocol)
{
Configuration = _formConfiguration?.Trim()
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
_showForm = false;
_toast.ShowSuccess("Connection saved.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteConnection(DataConnection conn)
{
var confirmed = await _confirmDialog.ShowAsync(
@@ -0,0 +1,213 @@
@page "/admin/ldap-mappings/create"
@page "/admin/ldap-mappings/{Id:int}/edit"
@using ScadaLink.Commons.Entities.Security
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISecurityRepository SecurityRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">
&larr; Back
</button>
</div>
<ConfirmDialog @ref="_confirmDialog" />
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(IsEditMode ? "Edit LDAP Mapping" : "Add LDAP Mapping")</h6>
<div class="mb-2">
<label class="form-label small">LDAP Group Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
</div>
<div class="mb-2">
<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>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@if (IsEditMode && _formRole.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Site Scope Rules</h6>
@if (_scopeRules.Count > 0)
{
<table class="table table-sm table-striped table-hover mb-3">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Site ID</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var rule in _scopeRules)
{
<tr>
<td>@rule.Id</td>
<td>@rule.SiteId</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScopeRule(rule)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-muted small mb-3">All sites (no restrictions)</p>
}
<div class="mb-2">
<label class="form-label small">Site ID</label>
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" />
</div>
@if (_scopeRuleError != null)
{
<div class="text-danger small mt-2">@_scopeRuleError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm" @onclick="AddScopeRule">Add</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => Id.HasValue;
private LdapGroupMapping? _editingMapping;
private string _formGroupName = string.Empty;
private string _formRole = string.Empty;
private string? _formError;
private List<SiteScopeRule> _scopeRules = new();
private int _scopeRuleSiteId;
private string? _scopeRuleError;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value);
if (_editingMapping != null)
{
_formGroupName = _editingMapping.LdapGroupName;
_formRole = _editingMapping.Role;
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
}
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
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();
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task AddScopeRule()
{
_scopeRuleError = null;
if (_scopeRuleSiteId <= 0)
{
_scopeRuleError = "Site ID must be a positive number.";
return;
}
try
{
var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId };
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
_scopeRuleSiteId = 0;
}
catch (Exception ex)
{
_scopeRuleError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteScopeRule(SiteScopeRule rule)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete scope rule for Site {rule.SiteId}? This cannot be undone.",
"Delete Scope Rule");
if (!confirmed) return;
try
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
await SecurityRepository.SaveChangesAsync();
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList();
}
catch (Exception ex)
{
_scopeRuleError = $"Delete failed: {ex.Message}";
}
}
}
@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Security
@inject ISecurityRepository SecurityRepository
@inject NavigationManager NavigationManager
<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>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/ldap-mappings/create")'>Add Mapping</button>
</div>
@if (_loading)
@@ -21,39 +22,6 @@
}
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">
@@ -95,13 +63,12 @@
}
@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>
<span class="text-muted small ms-2">(manage on edit page)</span>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditMapping(mapping)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteMapping(mapping.Id)">Delete</button>
</td>
@@ -110,29 +77,6 @@
</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>
@@ -142,19 +86,6 @@
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();
@@ -184,71 +115,6 @@
_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
@@ -269,45 +135,4 @@
}
}
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,163 @@
@page "/admin/sites/create"
@page "/admin/sites/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Communication
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">
&larr; Back
</button>
</div>
<ToastNotification @ref="_toast" />
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
<div class="mb-2">
<label class="form-label small">Identifier</label>
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
disabled="@IsEditMode" />
</div>
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
</div>
<h6 class="text-muted border-bottom pb-1">Node A</h6>
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="mb-3">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
<h6 class="text-muted border-bottom pb-1">Node B</h6>
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="mb-3">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => Id.HasValue;
private Site? _editingSite;
private string _formName = string.Empty;
private string _formIdentifier = string.Empty;
private string? _formDescription;
private string? _formNodeAAddress;
private string? _formNodeBAddress;
private string? _formGrpcNodeAAddress;
private string? _formGrpcNodeBAddress;
private string? _formError;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_editingSite = await SiteRepository.GetSiteByIdAsync(Id.Value);
if (_editingSite != null)
{
_formName = _editingSite.Name;
_formIdentifier = _editingSite.SiteIdentifier;
_formDescription = _editingSite.Description;
_formNodeAAddress = _editingSite.NodeAAddress;
_formNodeBAddress = _editingSite.NodeBAddress;
_formGrpcNodeAAddress = _editingSite.GrpcNodeAAddress;
_formGrpcNodeBAddress = _editingSite.GrpcNodeBAddress;
}
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/sites");
}
private async Task SaveSite()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName))
{
_formError = "Name is required.";
return;
}
try
{
if (_editingSite != null)
{
_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);
}
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();
CommunicationService.RefreshSiteAddresses();
NavigationManager.NavigateTo("/admin/sites");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
}
@@ -9,6 +9,7 @@
@inject ArtifactDeploymentService ArtifactDeploymentService
@inject CommunicationService CommunicationService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -22,7 +23,7 @@
}
Deploy Artifacts to All Sites
</button>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Site</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>Add Site</button>
</div>
</div>
@@ -39,62 +40,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingSite == null ? "Add New Site" : "Edit Site")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="col-md-3">
<label class="form-label small">Identifier</label>
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
disabled="@(_editingSite != null)" />
</div>
<div class="col-md-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-md-6">
<label class="form-label small">Node A Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="col-md-6">
<label class="form-label small">Node B Address (optional)</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-md-6">
<label class="form-label small">gRPC Node A Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
<div class="col-md-6">
<label class="form-label small">gRPC Node B Address (optional)</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@@ -146,7 +91,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditSite(site)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>Edit</button>
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@onclick="() => DeployArtifacts(site)"
disabled="@_deploying">Deploy Artifacts</button>
@@ -172,17 +117,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private Site? _editingSite;
private string _formName = string.Empty;
private string _formIdentifier = string.Empty;
private string? _formDescription;
private string? _formNodeAAddress;
private string? _formNodeBAddress;
private string? _formGrpcNodeAAddress;
private string? _formGrpcNodeBAddress;
private string? _formError;
private bool _deploying;
private ToastNotification _toast = default!;
@@ -217,94 +151,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingSite = null;
_formName = string.Empty;
_formIdentifier = string.Empty;
_formDescription = null;
_formNodeAAddress = null;
_formNodeBAddress = null;
_formGrpcNodeAAddress = null;
_formGrpcNodeBAddress = null;
_formError = null;
_showForm = true;
}
private void EditSite(Site site)
{
_editingSite = site;
_formName = site.Name;
_formIdentifier = site.SiteIdentifier;
_formDescription = site.Description;
_formNodeAAddress = site.NodeAAddress;
_formNodeBAddress = site.NodeBAddress;
_formGrpcNodeAAddress = site.GrpcNodeAAddress;
_formGrpcNodeBAddress = site.GrpcNodeBAddress;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingSite = null;
_formError = null;
}
private async Task SaveSite()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName))
{
_formError = "Name is required.";
return;
}
try
{
if (_editingSite != null)
{
_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);
}
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();
CommunicationService.RefreshSiteAddresses();
_showForm = false;
_editingSite = null;
_toast.ShowSuccess(_editingSite == null ? "Site created." : "Site updated.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteSite(Site site)
{
var confirmed = await _confirmDialog.ShowAsync(