diff --git a/src/NexusReader.UI.Shared/Components/AuthenticationStatePersister.razor b/src/NexusReader.UI.Shared/Components/AuthenticationStatePersister.razor new file mode 100644 index 0000000..22f87fb --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/AuthenticationStatePersister.razor @@ -0,0 +1,41 @@ +@using Microsoft.AspNetCore.Components.Authorization +@inject PersistentComponentState ApplicationState +@inject AuthenticationStateProvider AuthenticationStateProvider +@implements IDisposable + +@code { + private PersistingComponentStateSubscription _subscription; + + protected override void OnInitialized() + { + _subscription = ApplicationState.RegisterOnPersisting(PersistAuthenticationStateAsync); + } + + private async Task PersistAuthenticationStateAsync() + { + var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var principal = authenticationState.User; + + if (principal.Identity?.IsAuthenticated == true) + { + var email = principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? principal.Identity.Name; + var tenantId = principal.FindFirst("TenantId")?.Value ?? "global"; + var roles = string.Join(",", principal.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value)); + + if (email != null) + { + ApplicationState.PersistAsJson("UserInfo", new UserInfo + { + Email = email, + TenantId = tenantId, + Roles = roles + }); + } + } + } + + public void Dispose() + { + _subscription.Dispose(); + } +} diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor index 9484478..703382a 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Login.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -102,13 +102,21 @@ + @code { + [CascadingParameter] + private Task? AuthStateTask { get; set; } + [Parameter] [SupplyParameterFromQuery(Name = "error")] public string? ErrorCode { get; set; } + [Parameter] + [SupplyParameterFromQuery(Name = "returnUrl")] + public string? ReturnUrl { get; set; } + private LoginModel _loginModel = new(); private string? _errorMessage; private bool _isSubmitting; @@ -116,7 +124,7 @@ private bool _allowRegistration; private bool _allowPasswordReset; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { _allowRegistration = Configuration.GetValue("Features:AllowRegistration") ?? true; _allowPasswordReset = Configuration.GetValue("Features:AllowPasswordReset") ?? true; @@ -134,6 +142,15 @@ _ => "Wystąpił nieoczekiwany błąd podczas logowania." }; } + + if (AuthStateTask != null) + { + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) + { + NavigationManager.NavigateTo(string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl); + } + } } private async Task HandleLogin() diff --git a/src/NexusReader.UI.Shared/Routes.razor b/src/NexusReader.UI.Shared/Routes.razor index 644ac84..1459eed 100644 --- a/src/NexusReader.UI.Shared/Routes.razor +++ b/src/NexusReader.UI.Shared/Routes.razor @@ -1,3 +1,5 @@ + + diff --git a/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs index 7835422..c5f4f94 100644 --- a/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs +++ b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs @@ -4,20 +4,24 @@ using Microsoft.AspNetCore.Components.Authorization; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Constants; +using Microsoft.AspNetCore.Components; + namespace NexusReader.UI.Shared.Services; public class NexusAuthenticationStateProvider : AuthenticationStateProvider { private readonly INativeStorageService _storageService; + private readonly PersistentComponentState _persistentState; // SECURITY NOTE: We currently store roles in local storage to persist state across refreshes. // In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server) // or encrypted storage/JWT claims validation to prevent client-side role tampering. private const string TokenKey = StorageKeys.AuthToken; - public NexusAuthenticationStateProvider(INativeStorageService storageService) + public NexusAuthenticationStateProvider(INativeStorageService storageService, PersistentComponentState persistentState) { _storageService = storageService; + _persistentState = persistentState; } public void ClearCache() @@ -34,6 +38,18 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider { if (_cachedState != null) return _cachedState; + // 0. Hydrate state from SSR if available in PersistentComponentState + if (_persistentState.TryTakeFromJson("UserInfo", out var userInfo) && userInfo != null) + { + // Save to local storage for subsequent client-only transitions/refreshes + await _storageService.SaveSecureString(StorageKeys.UserEmail, userInfo.Email); + await _storageService.SaveSecureString(StorageKeys.UserTenant, userInfo.TenantId); + await _storageService.SaveSecureString(StorageKeys.UserRoles, userInfo.Roles); + + _cachedState = CreateState(userInfo.Email, userInfo.TenantId, "FederatedHydration", userInfo.Roles); + return _cachedState; + } + var tokenResult = await _storageService.GetSecureString(TokenKey); var token = tokenResult.IsSuccess ? tokenResult.Value : null; @@ -116,3 +132,10 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest))); } } + +public class UserInfo +{ + public string Email { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string Roles { get; set; } = string.Empty; +}