@implements IDisposable
@inject NavigationManager Navigation
@inject IJSRuntime JS
@code {
// Passive HTTP probes observe expiry but never advance activity. The JS module
// separately reports trusted browser input through an antiforgery-protected POST.
/// Server endpoint that reports live session validity.
internal const string PingUrl = "/auth/ping";
/// HTTP status returned by once the cookie has lapsed.
private const int Unauthorized = 401;
private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js";
/// How often the session validity is re-checked.
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
private CancellationTokenSource? _cts;
private IJSObjectReference? _module;
protected override void OnInitialized()
{
// The login page uses the same layout, so this component renders there
// too. Polling/redirecting on /login → /login would loop.
if (IsOnLoginPage) return;
_cts = new CancellationTokenSource();
_ = PollSessionAsync(_cts.Token);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || IsOnLoginPage) return;
try { _module ??= await JS.InvokeAsync("import", ModulePath); }
catch (JSDisconnectedException) { }
catch (JSException) { }
}
private bool IsOnLoginPage =>
Navigation.ToBaseRelativePath(Navigation.Uri)
.StartsWith("login", StringComparison.OrdinalIgnoreCase);
private async Task PollSessionAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try { await Task.Delay(PollInterval, token); }
catch (TaskCanceledException) { return; }
if (token.IsCancellationRequested) return;
await CheckSessionAsync();
}
}
///
/// Runs one liveness check: pings the server and, if the session has lapsed
/// server-side (HTTP 401), redirects to the login page. Exposed for tests
/// (CentralUI-025) so the redirect path can be exercised without waiting on
/// the poll interval.
///
internal async Task CheckSessionAsync()
{
if (IsOnLoginPage) return;
int status;
try
{
_module ??= await JS.InvokeAsync("import", ModulePath);
status = await _module.InvokeAsync("ping", PingUrl);
}
catch (JSDisconnectedException)
{
// Circuit gone — nothing to redirect.
return;
}
catch (JSException)
{
// Network blip or fetch failure: treat as inconclusive and retry on
// the next poll rather than logging an authenticated user out on a
// transient error.
return;
}
if (status == Unauthorized)
{
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
}
}
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
// The module reference is owned by the circuit's JS runtime; once the
// circuit is disposed disposing it would throw — fire-and-forget and
// swallow the expected disconnect.
if (_module is not null)
{
_ = DisposeModuleAsync(_module);
}
}
private static async Task DisposeModuleAsync(IJSObjectReference module)
{
try { await module.DisposeAsync(); }
catch (JSDisconnectedException) { /* circuit already gone */ }
}
}