@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 */ } } }