feat(ui): Hub Navigation, Profile Dashboard and Auth Stability Fixes (#31)
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>
This commit was merged in pull request #31.
This commit is contained in:
@@ -1,141 +1,263 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.UI.Shared.Constants;
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IIdentityService
|
||||
{
|
||||
Task<bool> RegisterAsync(string email, string password);
|
||||
Task<bool> LoginAsync(string email, string password, bool rememberMe = false);
|
||||
Task LogoutAsync();
|
||||
Task<UserProfile?> GetProfileAsync();
|
||||
Task<bool> RefreshTokenAsync();
|
||||
event Func<Task>? OnStateInvalidated;
|
||||
Task<Result> RegisterAsync(string email, string password);
|
||||
Task<Result> LoginAsync(string email, string password, bool rememberMe = false);
|
||||
Task<Result> LogoutAsync();
|
||||
Task<Result<UserProfile>> GetProfileAsync();
|
||||
Task<Result> RefreshTokenAsync();
|
||||
}
|
||||
|
||||
public record UserProfile(
|
||||
string Email,
|
||||
int AITokenLimit,
|
||||
int AITokensUsed,
|
||||
string CurrentPlan,
|
||||
string Email,
|
||||
int AITokensUsed,
|
||||
Guid TenantId,
|
||||
SubscriptionPlanDto Plan,
|
||||
int AverageQuizScore,
|
||||
string LastReadBookTitle);
|
||||
LastReadBookDto? LastReadBook)
|
||||
{
|
||||
// Helper properties for UI compatibility
|
||||
public string CurrentPlan => Plan?.Name ?? PlanConstants.DefaultPlanName;
|
||||
public int AITokenLimit => Plan?.AITokenLimit ?? PlanConstants.DefaultTokenLimit;
|
||||
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
|
||||
}
|
||||
|
||||
public class IdentityService : IIdentityService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INativeStorageService _storageService;
|
||||
private readonly NexusAuthenticationStateProvider _authStateProvider;
|
||||
private const string TokenKey = "nexus_auth_token";
|
||||
private const string RefreshTokenKey = "nexus_refresh_token";
|
||||
private readonly AuthenticationStateProvider? _authStateProvider;
|
||||
private const string TokenKey = StorageKeys.AuthToken;
|
||||
private const string RefreshTokenKey = StorageKeys.RefreshToken;
|
||||
private Task<UserProfile?>? _profileTask;
|
||||
private UserProfile? _cachedProfile;
|
||||
private DateTime _lastFetchAttempt = DateTime.MinValue;
|
||||
|
||||
public event Func<Task>? OnStateInvalidated;
|
||||
|
||||
public IdentityService(
|
||||
HttpClient httpClient,
|
||||
INativeStorageService storageService,
|
||||
NexusAuthenticationStateProvider authStateProvider)
|
||||
INativeStorageService storageService,
|
||||
AuthenticationStateProvider? authStateProvider = null)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_storageService = storageService;
|
||||
_authStateProvider = authStateProvider;
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterAsync(string email, string password)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/register", new { email, password });
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(TokenKey, result.AccessToken);
|
||||
if (!string.IsNullOrEmpty(result.RefreshToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
|
||||
}
|
||||
|
||||
// Option A: Fetch profile to get claims
|
||||
var profile = await GetProfileAsync();
|
||||
if (profile != null)
|
||||
{
|
||||
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
|
||||
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
|
||||
|
||||
_authStateProvider.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback if profile fetch fails
|
||||
_authStateProvider.NotifyUserAuthentication(email, "unknown");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
_storageService.RemoveSecure(TokenKey);
|
||||
_storageService.RemoveSecure(RefreshTokenKey);
|
||||
_storageService.RemoveSecure("nexus_user_email");
|
||||
_storageService.RemoveSecure("nexus_user_tenant");
|
||||
_authStateProvider.NotifyUserLogout();
|
||||
}
|
||||
|
||||
public async Task<UserProfile?> GetProfileAsync()
|
||||
public async Task<Result> RegisterAsync(string email, string password)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _httpClient.GetFromJsonAsync<UserProfile>("identity/profile");
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/register", new { email, password });
|
||||
return response.IsSuccessStatusCode ? Result.Ok() : Result.Fail("Rejestracja nie powiodła się.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd sieci podczas rejestracji.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_cachedProfile = null;
|
||||
LoginResponse? result = null;
|
||||
try
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd przetwarzania odpowiedzi serwera.").CausedBy(ex));
|
||||
}
|
||||
|
||||
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(TokenKey, result.AccessToken);
|
||||
if (!string.IsNullOrEmpty(result.RefreshToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
|
||||
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
||||
|
||||
var profileResult = await GetProfileAsync();
|
||||
if (profileResult.IsSuccess)
|
||||
{
|
||||
var profile = profileResult.Value;
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown");
|
||||
}
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
return Result.Fail("Nieprawidłowy e-mail lub hasło.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd połączenia z serwerem.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> LogoutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_cachedProfile = null;
|
||||
if (System.OperatingSystem.IsBrowser())
|
||||
{
|
||||
await _storageService.SaveSecureString(TokenKey, "");
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, "");
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, "");
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, "");
|
||||
}
|
||||
|
||||
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserLogout();
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd podczas wylogowywania.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfile>> GetProfileAsync()
|
||||
{
|
||||
if (_cachedProfile != null)
|
||||
{
|
||||
return Result.Ok(_cachedProfile);
|
||||
}
|
||||
|
||||
if (_profileTask != null)
|
||||
{
|
||||
var p = await _profileTask;
|
||||
return p != null ? Result.Ok(p) : Result.Fail("Nie znaleziono profilu.");
|
||||
}
|
||||
|
||||
_profileTask = GetProfileInternalAsync();
|
||||
var result = await _profileTask;
|
||||
return result != null ? Result.Ok(result) : Result.Fail("Błąd podczas pobierania profilu.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<UserProfile?> GetProfileInternalAsync()
|
||||
{
|
||||
if (!System.OperatingSystem.IsBrowser())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - _lastFetchAttempt < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_lastFetchAttempt = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync("identity/profile");
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
await LogoutAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var profile = await response.Content.ReadFromJsonAsync<UserProfile>();
|
||||
if (profile != null)
|
||||
{
|
||||
_cachedProfile = profile;
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_profileTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshTokenAsync()
|
||||
public async Task<Result> RefreshTokenAsync()
|
||||
{
|
||||
var result = await _storageService.GetSecureString(RefreshTokenKey);
|
||||
var refreshToken = result.IsSuccess ? result.Value : null;
|
||||
|
||||
if (string.IsNullOrEmpty(refreshToken)) return false;
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/refresh", new { refreshToken });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
try
|
||||
{
|
||||
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken))
|
||||
var result = await _storageService.GetSecureString(RefreshTokenKey);
|
||||
var refreshToken = result.IsSuccess ? result.Value : null;
|
||||
|
||||
if (string.IsNullOrEmpty(refreshToken)) return Result.Fail("Brak tokena odświeżania.");
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/refresh", new { refreshToken });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
await _storageService.SaveSecureString(TokenKey, loginResult.AccessToken);
|
||||
if (!string.IsNullOrEmpty(loginResult.RefreshToken))
|
||||
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
|
||||
}
|
||||
await _storageService.SaveSecureString(TokenKey, loginResult.AccessToken);
|
||||
if (!string.IsNullOrEmpty(loginResult.RefreshToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
|
||||
}
|
||||
|
||||
var profile = await GetProfileAsync();
|
||||
if (profile != null)
|
||||
{
|
||||
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
|
||||
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
|
||||
_authStateProvider.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
_cachedProfile = null;
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
|
||||
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
||||
|
||||
return true;
|
||||
var profileResult = await GetProfileAsync();
|
||||
if (profileResult.IsSuccess)
|
||||
{
|
||||
var profile = profileResult.Value;
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
return Result.Fail("Sesja wygasła.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd odświeżania sesji.").CausedBy(ex));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private class LoginResponse
|
||||
|
||||
Reference in New Issue
Block a user