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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">…</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>
|
||||
|
||||
@@ -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">…</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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
57
src/ScadaLink.CentralUI/Components/Shared/PagerWindow.cs
Normal file
57
src/ScadaLink.CentralUI/Components/Shared/PagerWindow.cs
Normal 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><li></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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user