diff --git a/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs b/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs index f989fb9..47f0451 100644 --- a/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs +++ b/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs @@ -44,12 +44,15 @@ public static class AuthEndpoints // Map LDAP groups to roles var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []); + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(30); + // Build claims from LDAP auth + role mapping var claims = new List { new(ClaimTypes.Name, authResult.Username ?? username), new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username), new(JwtTokenService.UsernameClaimType, authResult.Username ?? username), + new("expires_at", expiresAt.ToUnixTimeSeconds().ToString()), }; foreach (var role in roleMappingResult.Roles) @@ -74,7 +77,7 @@ public static class AuthEndpoints new AuthenticationProperties { IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(30) + ExpiresUtc = expiresAt }); context.Response.Redirect("/"); diff --git a/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor b/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor index 1e87d7d..011b4cf 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor @@ -25,3 +25,5 @@ @* Global host for IDialogService. One instance per layout renders all confirm/prompt dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@ + + diff --git a/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor b/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor new file mode 100644 index 0000000..d8d2ed6 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor @@ -0,0 +1,39 @@ +@implements IDisposable +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation + +@code { + private CancellationTokenSource? _cts; + + protected override async Task OnInitializedAsync() + { + var auth = await AuthStateProvider.GetAuthenticationStateAsync(); + if (auth.User.Identity?.IsAuthenticated != true) return; + + var claim = auth.User.FindFirst("expires_at")?.Value; + if (!long.TryParse(claim, out var unix)) return; + + var remaining = DateTimeOffset.FromUnixTimeSeconds(unix) - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) + { + Navigation.NavigateTo("/login", forceLoad: true); + return; + } + + _cts = new CancellationTokenSource(); + _ = ScheduleRedirectAsync(remaining + TimeSpan.FromSeconds(2), _cts.Token); + } + + private async Task ScheduleRedirectAsync(TimeSpan delay, CancellationToken token) + { + try { await Task.Delay(delay, token); } + catch (TaskCanceledException) { return; } + await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true)); + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + } +}