From da2c0d714ebf89be9f04488d6328b067099fb0b8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 03:32:17 -0400 Subject: [PATCH] refactor(ui/admin): card grid, search, kebab; LDAP scope-rule chips LdapMappings: flex header, search filter, per-row Edit + kebab Delete, @key, dropped Site-Scope-Rules cell in favor of a {n rule(s)} badge. LdapMappingForm: two stacked cards (Mapping then Site Scope Rules); scope rules render as removable chips with an inline "Add scope rule" form; create-mode disables the scope card with an explainer; role select gets form-text help. DataConnections:

in flex header, Bulk actions dropdown holding Expand/Collapse, hover-visible kebab on tree nodes mirroring the right-click context menu, aria-labels, "No connections match the filter." inline empty state. DataConnectionForm: Site rendered as readonly plaintext + lock-after- creation note in edit mode; parallel Primary endpoint / Backup endpoint headings; "Optional" badge on Backup when null; form-text on FailoverRetryCount. ApiKeys: search filter, Status column dropped (state now lives in the kebab menu label "Disable"/"Enable"), Edit + kebab actions, @key, aria-labels. ApiKeyForm: nested card removed; fixed-text Back header; real clipboard copy via IJSRuntime + toast confirmation. Test selector fix in DataConnectionFormTests for the new Site readonly-plaintext rendering. --- .../Components/Pages/Admin/ApiKeyForm.razor | 73 ++++++---- .../Components/Pages/Admin/ApiKeys.razor | 127 ++++++++++------- .../Pages/Admin/DataConnectionForm.razor | 19 ++- .../Pages/Admin/DataConnections.razor | 100 ++++++++++++-- .../Pages/Admin/LdapMappingForm.razor | 97 +++++++------ .../Components/Pages/Admin/LdapMappings.razor | 130 ++++++++++-------- .../DataConnectionFormTests.cs | 15 +- 7 files changed, 364 insertions(+), 197 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor index 9c372f9..3e2dbd6 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor @@ -6,20 +6,31 @@ @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject IInboundApiRepository InboundApiRepository @inject NavigationManager NavigationManager +@inject IJSRuntime JS
- @if (_saved) - { - ← Back to API Keys - } - else - { - ← Back - } -

@(_saved ? "API Key Created" : (_editingKey != null ? "Edit API Key" : "Add API Key"))

+ ← Back + · +

+ @if (_saved) + { + @:API Key Created + } + else if (IsEditMode) + { + @:Edit API Key + } + else + { + @:Add API Key + } +

+ + @if (_loading) { @@ -42,21 +53,17 @@ } else { -
-
-
- - -
- @if (_formError != null) - { -
@_formError
- } -
- - -
-
+
+ + +
+ @if (_formError != null) + { +
@_formError
+ } +
+ +
}
@@ -64,6 +71,8 @@ @code { [Parameter] public int? Id { get; set; } + private bool IsEditMode => _editingKey != null; + private ApiKey? _editingKey; private string _formName = string.Empty; private string? _formError; @@ -72,6 +81,8 @@ private bool _loading = true; private bool _saved; + private ToastNotification _toast = default!; + protected override async Task OnInitializedAsync() { try @@ -128,10 +139,18 @@ private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys"); - private void CopyKeyToClipboard() + private async Task CopyKeyToClipboard() { - // Note: JS interop for clipboard would be needed for actual copy. - // For now the key is displayed for manual copy. + if (_newlyCreatedKeyValue == null) return; + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue); + _toast.ShowSuccess("Copied to clipboard."); + } + catch + { + _toast.ShowError("Copy failed."); + } } private static string GenerateApiKey() diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor index a36b47e..baee409 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor @@ -13,7 +13,7 @@
- + @if (_loading) { @@ -25,59 +25,75 @@ } else { - - - - - - - - - - - - @if (_keys.Count == 0) - { +
+ +
+ + @if (_keys.Count == 0) + { +

No API keys configured.

+ } + else if (!FilteredKeys.Any()) + { +

No API keys match the filter.

+ } + else + { +
IDNameKey ValueStatusActions
+ - + + + + - } - @foreach (var key in _keys) - { - - - - - - - - } - -
No API keys configured.IDNameKey ValueActions
@key.Id@key.Name@MaskKeyValue(key.KeyValue) - @if (key.IsEnabled) - { - Enabled - } - else - { - Disabled - } - - - @if (key.IsEnabled) - { - - } - else - { - - } - -
+ + + @foreach (var key in FilteredKeys) + { + + @key.Id + + @key.Name + @if (!key.IsEnabled) + { + Disabled + } + + @MaskKeyValue(key.KeyValue) + +
+ + +
+ + + } + + + } } @@ -85,10 +101,17 @@ private List _keys = new(); private bool _loading = true; private string? _errorMessage; + private string _search = string.Empty; private ToastNotification _toast = default!; private ConfirmDialog _confirmDialog = default!; + private IEnumerable FilteredKeys => + string.IsNullOrWhiteSpace(_search) + ? _keys + : _keys.Where(k => + k.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false); + protected override async Task OnInitializedAsync() { await LoadDataAsync(); diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor index b3aada6..90d04fd 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor @@ -32,9 +32,11 @@ @if (_siteLocked) { - + +
Site is locked after creation.
} else { @@ -52,13 +54,20 @@ +
Primary endpoint
-
Backup Endpoint
+
+ Backup endpoint + @if (!_showBackup) + { + Optional + } +
@if (!_showBackup) {
@@ -77,7 +86,7 @@ -
Retries on active endpoint before switching to backup (default: 3)
+
Retries before failing over to backup endpoint.
+ +
+ + + + - + @if (_loading) { @@ -21,21 +48,17 @@ } else { -
Connections
-
- + -
- - - - -
+ @if (!string.IsNullOrWhiteSpace(_searchText) && _matchKeys.Count == 0 && _treeRoots.Count > 0) + { +

No connections match the filter.

+ } + @node.Label @node.Connection!.Protocol } + + + @if (node.Kind == DcNodeKind.Site) @@ -91,6 +149,24 @@ } + + @code { record DcTreeNode(string Key, string Label, DcNodeKind Kind, List Children, int? SiteId = null, DataConnection? Connection = null); diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor index 8d76862..a5183f9 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor @@ -1,24 +1,28 @@ @page "/admin/ldap-mappings/create" @page "/admin/ldap-mappings/{Id:int}/edit" @using ScadaLink.Commons.Entities.Security +@using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Security @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject ISecurityRepository SecurityRepository +@inject ISiteRepository SiteRepository @inject NavigationManager NavigationManager
-
- +
-
@(IsEditMode ? "Edit LDAP Mapping" : "Add LDAP Mapping")
+
Mapping
@@ -31,6 +35,7 @@ +
Deployment role: configure site scope below after saving.
@if (_formError != null) { @@ -43,56 +48,60 @@
- @if (IsEditMode && _formRole.Equals("Deployment", StringComparison.OrdinalIgnoreCase)) - { -
-
-
Site Scope Rules
+
+
+
Site Scope Rules
+ @if (!IsEditMode) + { +

Save the mapping first to configure site scope.

+ } + else + { @if (_scopeRules.Count > 0) { - - - - - - - - - - @foreach (var rule in _scopeRules) - { - - - - - - } - -
IDSite IDActions
@rule.Id@rule.SiteId - -
+
+ @foreach (var rule in _scopeRules) + { + var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}"; + + @siteName + + + } +
} else {

All sites (no restrictions)

} -
- - +
+
+ + +
+
+ +
@if (_scopeRuleError != null) {
@_scopeRuleError
} -
- -
-
+ }
- } +
@code { @@ -106,6 +115,8 @@ private string? _formError; private List _scopeRules = new(); + private List _sites = new(); + private Dictionary _siteLookup = new(); private int _scopeRuleSiteId; private string? _scopeRuleError; @@ -113,6 +124,9 @@ protected override async Task OnInitializedAsync() { + _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + _siteLookup = _sites.ToDictionary(s => s.Id); + if (Id.HasValue) { _editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value); @@ -174,7 +188,7 @@ if (_scopeRuleSiteId <= 0) { - _scopeRuleError = "Site ID must be a positive number."; + _scopeRuleError = "Select a site to add a scope rule."; return; } @@ -194,9 +208,10 @@ private async Task DeleteScopeRule(SiteScopeRule rule) { + var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}"; var confirmed = await _confirmDialog.ShowAsync( - $"Delete scope rule for Site {rule.SiteId}? This cannot be undone.", - "Delete Scope Rule"); + $"Remove scope rule for '{siteName}'?", + "Remove Scope Rule"); if (!confirmed) return; try diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor index 0ffd1f8..3880189 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor @@ -22,61 +22,75 @@ } else { - @* Mappings table *@ - - - - - - - - - - - - @if (_mappings.Count == 0) - { - - - - } - @foreach (var mapping in _mappings) - { - - - - - - - - } - -
IDLDAP Group NameRoleSite Scope RulesActions
No mappings configured.
@mapping.Id@mapping.LdapGroupName@mapping.Role - @{ - var rules = _scopeRules.GetValueOrDefault(mapping.Id); - } - @if (rules != null && rules.Count > 0) - { - @foreach (var rule in rules) - { - Site @rule.SiteId - } - } - else - { - All sites - } - @if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase)) - { - (manage on edit page) - } - - - -
+
+ +
+ @if (_mappings.Count == 0) + { +

No mappings configured.

+ } + else if (!FilteredMappings.Any()) + { +

No mappings match the filter.

+ } + else + { + + + + + + + + + + + + @foreach (var mapping in FilteredMappings) + { + var rules = _scopeRules.GetValueOrDefault(mapping.Id); + var ruleCount = rules?.Count ?? 0; + + + + + + + + } + +
IDLDAP Group NameRoleSite ScopeActions
@mapping.Id@mapping.LdapGroupName@mapping.Role + @if (ruleCount > 0) + { + @ruleCount rule(s) + } + else + { + All sites + } + +
+ + +
+
+ } }
@@ -85,6 +99,14 @@ private Dictionary> _scopeRules = new(); private bool _loading = true; private string? _errorMessage; + private string _search = string.Empty; + + private IEnumerable FilteredMappings => + string.IsNullOrWhiteSpace(_search) + ? _mappings + : _mappings.Where(m => + (m.LdapGroupName?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) || + (m.Role?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false)); protected override async Task OnInitializedAsync() { diff --git a/tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs b/tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs index 1a3e059..d494cd5 100644 --- a/tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs @@ -60,10 +60,12 @@ public class DataConnectionFormTests : BunitContext .First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true) .Change("not-a-url"); - // Name field (the first text input that is NOT the OPC URL) + // Name field (the first editable text input that is NOT the OPC URL). + // Site renders as a readonly plaintext input when locked — skip it. cut.FindAll("input[type='text']") - .First(i => i.GetAttribute("placeholder") is null - || !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")) + .First(i => !i.HasAttribute("readonly") + && (i.GetAttribute("placeholder") is null + || !i.GetAttribute("placeholder")!.StartsWith("opc.tcp"))) .Change("My Connection"); await cut.FindAll("button") @@ -82,10 +84,11 @@ public class DataConnectionFormTests : BunitContext var cut = RenderForCreateSite(1); - // Name + // Name (skip readonly Site plaintext input) cut.FindAll("input[type='text']") - .First(i => i.GetAttribute("placeholder") is null - || !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")) + .First(i => !i.HasAttribute("readonly") + && (i.GetAttribute("placeholder") is null + || !i.GetAttribute("placeholder")!.StartsWith("opc.tcp"))) .Change("PLC-1"); // Endpoint URL cut.FindAll("input[type='text']")