2e23a032d3
This PR implements the Hub Navigation system and the Profile Dashboard, while resolving critical session synchronization issues. ### Key Changes - **Hub Navigation**: Introduced `MainHubLayout` with a premium glassmorphism sidebar, providing access to Dashboard, Library, Concepts Map, and Profile. - **Profile Dashboard**: Implemented a high-fidelity Profile page (#27) with learning metrics, AI token usage tracking, and system rank visualization. - **Stability Fixes**: - Resolved an infinite network loop on the `/profile` page by implementing request deduplication and in-memory caching in `IdentityService`. - Added environment-aware guards to prevent illegal JavaScript interop calls during server-side prerendering. - Implemented automatic session invalidation on `401 Unauthorized` responses to handle stale authentication states gracefully. - **Reader Integration**: Added a "Return to Dashboard" option in the reader toolbar (#26). Closes #26 Closes #27 Reviewed-on: #31 Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
95 lines
3.7 KiB
C#
95 lines
3.7 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using NexusReader.Application.Abstractions.Services;
|
|
using NexusReader.UI.Shared.Constants;
|
|
|
|
namespace NexusReader.UI.Shared.Services;
|
|
|
|
public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
|
{
|
|
private readonly INativeStorageService _storageService;
|
|
private const string TokenKey = StorageKeys.AuthToken;
|
|
|
|
public NexusAuthenticationStateProvider(INativeStorageService storageService)
|
|
{
|
|
_storageService = storageService;
|
|
}
|
|
|
|
public void ClearCache()
|
|
{
|
|
_cachedState = null;
|
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
|
}
|
|
|
|
private AuthenticationState? _cachedState;
|
|
|
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
{
|
|
try
|
|
{
|
|
if (_cachedState != null) return _cachedState;
|
|
|
|
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
|
var token = tokenResult.IsSuccess ? tokenResult.Value : null;
|
|
|
|
// 1. Try Token-based auth
|
|
if (!string.IsNullOrWhiteSpace(token))
|
|
{
|
|
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
|
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
|
|
|
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
|
{
|
|
_cachedState = CreateState(emailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "OpaqueBearer");
|
|
return _cachedState;
|
|
}
|
|
}
|
|
|
|
// 2. Try Cookie-based auth indicators
|
|
var storedEmailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
|
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
|
|
{
|
|
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
|
_cachedState = CreateState(storedEmailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "CookieAuth");
|
|
return _cachedState;
|
|
}
|
|
|
|
// 3. Fallback: If we have no local info, we might still have a cookie (e.g. after refresh or Google login).
|
|
// We should return anonymous for now but trigger a background check if we're in WASM.
|
|
// Wait! In WASM, the first GetAuthenticationStateAsync is awaited.
|
|
// We can do a quick check here if it's the first time.
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
}
|
|
|
|
private AuthenticationState CreateState(string email, string tenantId, string authType)
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new Claim(ClaimTypes.Name, email),
|
|
new Claim(ClaimTypes.Email, email),
|
|
new Claim("TenantId", tenantId)
|
|
};
|
|
var identity = new ClaimsIdentity(claims, authType);
|
|
return new AuthenticationState(new ClaimsPrincipal(identity));
|
|
}
|
|
|
|
public void NotifyUserAuthentication(string email, string tenantId)
|
|
{
|
|
_cachedState = CreateState(email, tenantId, "OpaqueBearer");
|
|
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
|
}
|
|
|
|
public void NotifyUserLogout()
|
|
{
|
|
_cachedState = null;
|
|
var guest = new ClaimsPrincipal(new ClaimsIdentity());
|
|
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
|
|
}
|
|
}
|