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:
@@ -2,7 +2,8 @@ using NexusReader.Web.Components;
|
||||
using NexusReader.Application;
|
||||
using NexusReader.Infrastructure;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Application.Queries.User;
|
||||
using MediatR;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
@@ -52,9 +53,8 @@ builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
});
|
||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||
|
||||
builder.Services.AddScoped<IIdentityService, NexusReader.UI.Shared.Services.IdentityService>();
|
||||
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<IIdentityService, NexusReader.Web.New.Services.ServerIdentityService>();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
@@ -93,14 +93,24 @@ builder.Services.AddIdentityApiEndpoints<NexusUser>()
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/account/login";
|
||||
options.LogoutPath = "/account/logout";
|
||||
options.AccessDeniedPath = "/account/access-denied";
|
||||
options.Cookie.Name = "NexusReader.Auth";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
|
||||
options.Events.OnRedirectToLogin = context =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api"))
|
||||
var isApiRequest = context.Request.Path.StartsWithSegments("/api") ||
|
||||
context.Request.Path.StartsWithSegments("/identity") ||
|
||||
context.Request.Headers["Accept"].ToString().Contains("application/json");
|
||||
|
||||
if (isApiRequest)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -216,9 +226,11 @@ app.MapStaticAssets();
|
||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||
|
||||
// API endpoint for WASM client to fetch EPUB content
|
||||
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
|
||||
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService, ClaimsPrincipal user) =>
|
||||
{
|
||||
var result = await epubService.GetEpubContentAsync(index);
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var result = await epubService.GetEpubContentAsync(index, userId);
|
||||
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||
@@ -421,38 +433,15 @@ app.MapGet("/identity/callback/google", async (
|
||||
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
||||
});
|
||||
|
||||
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||
app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId == null) return Results.Unauthorized();
|
||||
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
var result = await mediator.Send(new GetUserProfileQuery(userId));
|
||||
if (result.IsFailed) return Results.NotFound(result.Errors.FirstOrDefault()?.Message);
|
||||
|
||||
var profile = await dbContext.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => new UserProfileDto
|
||||
{
|
||||
Email = u.Email ?? string.Empty,
|
||||
AITokensUsed = u.AITokensUsed,
|
||||
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||
{
|
||||
Id = u.SubscriptionPlan.Id,
|
||||
Name = u.SubscriptionPlan.PlanName,
|
||||
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
||||
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
||||
} : new SubscriptionPlanDto(),
|
||||
AverageQuizScore = u.QuizResults.Any() ? (int)u.QuizResults.Average(q => q.Percentage) : 0,
|
||||
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Title = e.Title
|
||||
}).FirstOrDefault()
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (profile == null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(profile);
|
||||
return Results.Ok(result.Value);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Security.Claims;
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.Queries.User;
|
||||
using MediatR;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.Web.New.Services;
|
||||
|
||||
public class ServerIdentityService : IIdentityService
|
||||
{
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public event Func<Task>? OnStateInvalidated;
|
||||
|
||||
public ServerIdentityService(
|
||||
UserManager<NexusUser> userManager,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IMediator mediator)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
=> throw new NotSupportedException("Use standard Identity endpoints for login on server.");
|
||||
|
||||
public Task<Result> LogoutAsync()
|
||||
=> throw new NotSupportedException("Use standard Identity endpoints for logout on server.");
|
||||
|
||||
public Task<Result> RegisterAsync(string email, string password)
|
||||
=> throw new NotSupportedException("Use standard Identity endpoints for registration on server.");
|
||||
|
||||
public Task<Result> RefreshTokenAsync() => Task.FromResult(Result.Ok());
|
||||
|
||||
public async Task<Result<UserProfile>> GetProfileAsync()
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User;
|
||||
if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated.");
|
||||
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId == null) return Result.Fail("User ID not found.");
|
||||
|
||||
var result = await _mediator.Send(new GetUserProfileQuery(userId));
|
||||
if (result.IsFailed) return Result.Fail(result.Errors);
|
||||
|
||||
var dto = result.Value;
|
||||
|
||||
// Map DTO to UI record
|
||||
var profile = new UserProfile(
|
||||
dto.Email,
|
||||
dto.AITokensUsed,
|
||||
dto.TenantId,
|
||||
dto.Plan,
|
||||
dto.AverageQuizScore,
|
||||
dto.LastReadBook
|
||||
);
|
||||
|
||||
return Result.Ok(profile);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user