fix(central-ui): resolve CentralUI-015..019 — pager windowing, logout CSRF, narrowed catch blocks, coverage; CentralUI-015 re-triaged Won't Fix

This commit is contained in:
Joseph Doherty
2026-05-16 22:04:21 -04:00
parent 404216b4ee
commit d7b275fc9b
18 changed files with 772 additions and 50 deletions

View File

@@ -124,17 +124,16 @@ public static class AuthEndpoints
});
}).DisableAntiforgery();
// Logout is a state-changing authenticated action (CentralUI-017): it
// keeps antiforgery validation enabled so it cannot be triggered
// cross-site. The NavMenu sign-out form includes the antiforgery token
// (rendered by the <AntiforgeryToken /> component). There is deliberately
// no GET /logout route — a state-changing GET is itself a CSRF vector
// (an <img src="/logout"> would forcibly log a user out).
endpoints.MapPost("/auth/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
context.Response.Redirect("/login");
}).DisableAntiforgery();
// GET /logout — allows direct navigation to logout (redirects to login after sign-out)
endpoints.MapGet("/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/login");
});
return endpoints;

View File

@@ -101,6 +101,9 @@
<div class="d-flex justify-content-between align-items-center">
<span class="text-light small">@context.User.FindFirst("DisplayName")?.Value</span>
<form method="post" action="/auth/logout" data-enhance="false">
@* CentralUI-017: logout is a state-changing POST and is
CSRF-protected — the antiforgery token is required. *@
<AntiforgeryToken />
<button type="submit" class="btn btn-outline-light btn-sm py-0 px-2">Sign Out</button>
</form>
</div>

View File

@@ -13,6 +13,7 @@
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
@inject IDialogService Dialog
@inject Microsoft.Extensions.Logging.ILogger<Sites> Logger
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -310,8 +311,15 @@
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
catch (Microsoft.JSInterop.JSDisconnectedException)
{
// Circuit gone — the user has navigated away; nothing to surface.
}
catch (Microsoft.JSInterop.JSException ex)
{
// CentralUI-018: a real clipboard failure (e.g. permission denied)
// is logged, not silently swallowed.
Logger.LogWarning(ex, "Clipboard copy failed.");
_toast.ShowError("Copy failed.");
}
}

View File

@@ -165,12 +165,21 @@
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li>
@for (int i = 1; i <= _totalPages; i++)
@foreach (var page in ScadaLink.CentralUI.Components.Shared.PagerWindow.Build(_currentPage, _totalPages))
{
var page = i;
<li class="page-item @(page == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(page)">@(page)</button>
</li>
if (page == 0)
{
<li class="page-item disabled">
<span class="page-link">&hellip;</span>
</li>
}
else
{
var p = page;
<li class="page-item @(p == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
</li>
}
}
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>

View File

@@ -59,12 +59,21 @@
aria-disabled="@((_currentPage <= 1).ToString().ToLowerInvariant())"
@onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li>
@for (int i = 1; i <= _totalPages; i++)
@foreach (var page in PagerWindow.Build(_currentPage, _totalPages))
{
var page = i;
<li class="page-item @(page == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(page)">@(page)</button>
</li>
if (page == 0)
{
<li class="page-item disabled">
<span class="page-link">&hellip;</span>
</li>
}
else
{
var p = page;
<li class="page-item @(p == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
</li>
}
}
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" type="button"

View File

@@ -23,6 +23,13 @@ public class DialogService : IDialogService
/// </summary>
public DialogState? Current { get; private set; }
// CentralUI-015: the pending dialog result is held in a typed TCS that the
// host completes directly via Resolve(). The previous implementation
// projected the result through Task.ContinueWith(..., TaskScheduler.Default),
// which ran the projection lambda on a thread-pool thread. Completing a
// strongly-typed TCS directly removes that off-render-thread hop entirely —
// the awaiting caller resumes on whatever SynchronizationContext it captured
// (the Blazor renderer's, for an event-handler caller).
private TaskCompletionSource<object?>? _tcs;
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
@@ -32,7 +39,7 @@ public class DialogService : IDialogService
_tcs = tcs;
Current = new DialogState(title, DialogKind.Confirm, message, danger, PromptInitial: string.Empty, Placeholder: null);
OnChange?.Invoke();
return tcs.Task.ContinueWith(t => t.Result is bool b && b, TaskScheduler.Default);
return Project(tcs.Task, static r => r is bool b && b);
}
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
@@ -42,7 +49,18 @@ public class DialogService : IDialogService
_tcs = tcs;
Current = new DialogState(title, DialogKind.Prompt, label, Danger: false, PromptInitial: initialValue, Placeholder: placeholder);
OnChange?.Invoke();
return tcs.Task.ContinueWith(t => t.Result as string, TaskScheduler.Default);
return Project(tcs.Task, static r => r as string);
}
/// <summary>
/// Awaits the host's result and projects it to the caller's type. The
/// <c>await</c> here resumes on the caller's captured context (the renderer
/// sync context for an event-handler caller), not a thread-pool thread.
/// </summary>
private static async Task<TResult> Project<TResult>(Task<object?> source, Func<object?, TResult> selector)
{
var result = await source.ConfigureAwait(false);
return selector(result);
}
/// <summary>

View File

@@ -1,6 +1,7 @@
@namespace ScadaLink.CentralUI.Components.Shared
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
@if (ShowToolbar)
{
@@ -112,15 +113,45 @@
_dotNetRef);
_initialized = true;
}
catch
catch (InvalidOperationException)
{
// Prerendering or JS not ready — swallow; subsequent render will retry.
// Prerendering: JS interop is not available yet — the next
// (interactive) render retries. Expected, not logged.
}
catch (JSDisconnectedException)
{
// Circuit disconnected before init completed — nothing to do.
}
catch (JSException ex)
{
// A genuine Monaco init failure — surface it instead of hiding it.
Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id);
}
}
else if (_initialized && (Value ?? "") != _lastSentValue)
{
_lastSentValue = Value ?? "";
try { await JS.InvokeVoidAsync("MonacoBlazor.setValue", _id, _lastSentValue); } catch { }
await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue);
}
}
/// <summary>
/// Invokes a Monaco JS function, swallowing the expected disconnect case but
/// logging any genuine JS error (CentralUI-018) so failures are not silent.
/// </summary>
private async ValueTask SafeInvokeAsync(string fn, string action, params object?[] args)
{
try
{
await JS.InvokeVoidAsync(fn, args);
}
catch (JSDisconnectedException)
{
// Circuit gone — the editor no longer exists; nothing to log.
}
catch (JSException ex)
{
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to {Action}.", _id, action);
}
}
@@ -139,7 +170,7 @@
public async Task RevealLineAsync(int line, int column = 1)
{
if (!_initialized) return;
try { await JS.InvokeVoidAsync("MonacoBlazor.revealLine", _id, line, column); } catch { }
await SafeInvokeAsync("MonacoBlazor.revealLine", "reveal line", _id, line, column);
}
/// <summary>
@@ -161,32 +192,34 @@
private async Task FormatAsync()
{
if (!_initialized) return;
try { await JS.InvokeVoidAsync("MonacoBlazor.format", _id); } catch { }
await SafeInvokeAsync("MonacoBlazor.format", "format document", _id);
}
private async Task ToggleWrap()
{
_wrap = !_wrap;
try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "wordWrap", _wrap ? "on" : "off"); } catch { }
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle word wrap", _id, "wordWrap", _wrap ? "on" : "off");
}
private async Task ToggleMinimap()
{
_minimap = !_minimap;
try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "minimap", new { enabled = _minimap }); } catch { }
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
}
private async Task ToggleTheme()
{
_dark = !_dark;
try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "theme", _dark ? "vs-dark" : "vs"); } catch { }
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle theme", _id, "theme", _dark ? "vs-dark" : "vs");
}
public async ValueTask DisposeAsync()
{
if (_initialized)
{
try { await JS.InvokeVoidAsync("MonacoBlazor.dispose", _id); } catch { }
// Disposal commonly races a circuit disconnect — JSDisconnectedException
// here is expected and silent; a real JSException is still logged.
await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id);
}
_dotNetRef?.Dispose();
}

View File

@@ -0,0 +1,57 @@
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Pure helper for windowed pagination (CentralUI-016). Computes the set of
/// page numbers a pager should render: always the first and last page plus a
/// small range around the current page, with the rest elided. Keeps the
/// rendered button count bounded regardless of the total page count, instead
/// of emitting one <c>&lt;li&gt;</c> per page.
/// </summary>
public static class PagerWindow
{
/// <summary>
/// Returns the page numbers to render. A value of <c>0</c> in the result is
/// an ellipsis placeholder (a gap between non-contiguous page numbers).
/// <paramref name="radius"/> is how many pages to show on each side of the
/// current page.
/// </summary>
public static IReadOnlyList<int> Build(int currentPage, int totalPages, int radius = 2)
{
if (totalPages <= 1)
{
return totalPages == 1 ? new[] { 1 } : Array.Empty<int>();
}
currentPage = Math.Clamp(currentPage, 1, totalPages);
// Small enough that windowing buys nothing — render every page.
var maxUnwindowed = 2 * radius + 5;
if (totalPages <= maxUnwindowed)
{
return Enumerable.Range(1, totalPages).ToList();
}
var pages = new SortedSet<int> { 1, totalPages };
for (var p = currentPage - radius; p <= currentPage + radius; p++)
{
if (p >= 1 && p <= totalPages)
{
pages.Add(p);
}
}
// Walk the sorted set, inserting an ellipsis (0) wherever a gap exists.
var result = new List<int>();
var previous = 0;
foreach (var page in pages)
{
if (previous != 0 && page - previous > 1)
{
result.Add(0); // ellipsis
}
result.Add(page);
previous = page;
}
return result;
}
}

View File

@@ -126,29 +126,56 @@ else
if (_contextMenuNeedsFocus && _showContextMenu)
{
_contextMenuNeedsFocus = false;
try { await _contextMenuRef.FocusAsync(); } catch { /* element may have been disposed if dismissed during render */ }
// The context-menu element may have been removed (menu dismissed
// during render) or the circuit disconnected — both are expected.
try { await _contextMenuRef.FocusAsync(); }
catch (Microsoft.JSInterop.JSException) { }
catch (Microsoft.JSInterop.JSDisconnectedException) { }
catch (InvalidOperationException) { }
}
if (firstRender && StorageKey != null)
{
var json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
string? json = null;
try
{
json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
}
catch (Microsoft.JSInterop.JSDisconnectedException)
{
// Circuit disconnected before the storage read completed — there
// is nothing to restore and nothing to log.
}
_storageLoaded = true;
// CentralUI-018: a corrupt or wrong-shaped treeviewStorage payload
// must not throw out of OnAfterRenderAsync. Guard the deserialize
// and treat an unparseable payload as "no prior state".
List<string>? keys = null;
if (json != null)
{
var keys = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
if (keys != null)
try
{
// Union (don't replace): callers may have invoked RevealNode before
// this async storage load completed. Preserving those reveal-added
// keys ensures deep-link reveal isn't clobbered by the restore.
foreach (var k in keys) _expandedKeys.Add(k);
_initialExpansionApplied = true;
keys = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
}
catch (System.Text.Json.JsonException)
{
keys = null;
}
}
if (keys != null)
{
// Union (don't replace): callers may have invoked RevealNode before
// this async storage load completed. Preserving those reveal-added
// keys ensures deep-link reveal isn't clobbered by the restore.
foreach (var k in keys) _expandedKeys.Add(k);
_initialExpansionApplied = true;
}
else if (InitiallyExpanded != null && _items is { Count: > 0 } && !_initialExpansionApplied)
{
// Storage returned null (no prior state) — fall back to InitiallyExpanded
// Storage returned null or a corrupt payload (no usable prior
// state) — fall back to InitiallyExpanded.
_initialExpansionApplied = true;
ApplyInitialExpansion(_items);
}

View File

@@ -4,6 +4,7 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Logging
@using Microsoft.JSInterop
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using ScadaLink.CentralUI