Refactor: Web Consolidation and Identity Stabilization (#40)

## Overview
This PR completes the architectural consolidation of the web project and stabilizes the Identity-based authentication flow for the NexusReader application. It also refines the UI aesthetic for the Book Ingestion Modal as requested in #33.

## Key Changes
- **Project Consolidation**: Fully merged `NexusReader.Web.New` into `NexusReader.Web`. This includes updating all namespace references, VS Code launch/task configurations, and CI/CD (`Dockerfile`).
- **Identity Stabilization**:
  - Implemented `IIdentityService` on the server using `SignInManager<NexusUser>` and `UserManager<NexusUser>`.
  - Fixed registration logic to include mandatory fields (`SubscriptionPlanId`, `TenantId`).
  - Updated `Login.razor` to force a page reload on successful login, ensuring proper synchronization of authentication cookies between SignalR and the browser.
- **UI/UX Refinement**:
  - Updated `BookIngestionModal` styling to follow the **Nexus Neon** design system.
  - Added premium button styles with hover effects and glows.
  - Improved modal layout and interaction feedback (shimmer effects, spinner colors).
- **Cleanup**: Removed obsolete interfaces and constants that were superseded by newer Application layer implementations.

## Verification
- Successfully built the solution: `dotnet build NexusReader.slnx --no-restore`
- Verified project structure and file moves.
- Validated server-side authentication logic.

Fixes #33

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #40
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #40.
This commit is contained in:
2026-05-11 19:16:30 +00:00
committed by Marek Jaisński
parent f433e3c74a
commit fe5ff81c98
61 changed files with 1092 additions and 312 deletions
@@ -11,5 +11,5 @@ public interface IReaderNavigationService
Task GoToChapter(int index);
Task GoToNextChapter();
Task GoToPreviousChapter();
void UpdateMetadata(int currentIndex, int totalChapters, string title);
Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title);
}
@@ -2,35 +2,11 @@ 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 NexusReader.Application.Constants;
using FluentResults;
namespace NexusReader.UI.Shared.Services;
public interface IIdentityService
{
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 AITokensUsed,
Guid TenantId,
SubscriptionPlanDto Plan,
int AverageQuizScore,
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;
@@ -38,8 +14,8 @@ public class IdentityService : IIdentityService
private readonly AuthenticationStateProvider? _authStateProvider;
private const string TokenKey = StorageKeys.AuthToken;
private const string RefreshTokenKey = StorageKeys.RefreshToken;
private Task<UserProfile?>? _profileTask;
private UserProfile? _cachedProfile;
private Task<UserProfileDto?>? _profileTask;
private UserProfileDto? _cachedProfile;
private DateTime _lastFetchAttempt = DateTime.MinValue;
public event Func<Task>? OnStateInvalidated;
@@ -71,7 +47,7 @@ public class IdentityService : IIdentityService
{
try
{
var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password });
var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password });
if (response.IsSuccessStatusCode)
{
@@ -104,11 +80,15 @@ public class IdentityService : IIdentityService
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());
var rolesStr = string.Join(",", profile.Roles ?? Array.Empty<string>());
await _storageService.SaveSecureString(StorageKeys.UserRoles, rolesStr);
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString(), rolesStr);
}
else
{
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown");
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown", "");
}
return Result.Ok();
@@ -132,6 +112,7 @@ public class IdentityService : IIdentityService
await _storageService.SaveSecureString(RefreshTokenKey, "");
await _storageService.SaveSecureString(StorageKeys.UserEmail, "");
await _storageService.SaveSecureString(StorageKeys.UserTenant, "");
await _storageService.SaveSecureString(StorageKeys.UserRoles, "");
}
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
@@ -146,7 +127,7 @@ public class IdentityService : IIdentityService
}
}
public async Task<Result<UserProfile>> GetProfileAsync()
public async Task<Result<UserProfileDto>> GetProfileAsync()
{
if (_cachedProfile != null)
{
@@ -166,7 +147,7 @@ public class IdentityService : IIdentityService
private async Task<UserProfile?> GetProfileInternalAsync()
private async Task<UserProfileDto?> GetProfileInternalAsync()
{
if (!System.OperatingSystem.IsBrowser())
{
@@ -191,13 +172,17 @@ public class IdentityService : IIdentityService
if (response.IsSuccessStatusCode)
{
var profile = await response.Content.ReadFromJsonAsync<UserProfile>();
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
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());
var rolesStr = string.Join(",", profile.Roles ?? Array.Empty<string>());
await _storageService.SaveSecureString(StorageKeys.UserRoles, rolesStr);
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString(), rolesStr);
}
return profile;
}
@@ -246,7 +231,11 @@ public class IdentityService : IIdentityService
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());
var rolesStr = string.Join(",", profile.Roles ?? Array.Empty<string>());
await _storageService.SaveSecureString(StorageKeys.UserRoles, rolesStr);
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString(), rolesStr);
}
return Result.Ok();
@@ -1,4 +1,5 @@
using NexusReader.Application.Abstractions.Services;
using FluentResults;
using NexusReader.Application.Queries.Graph;
using NexusReader.Application.Queries.Quiz;
using NexusReader.UI.Shared.Services;
@@ -77,7 +78,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
await _graphService.SetActiveNode(blockId);
}
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
public async Task<Result<KnowledgePacket>> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{
await _quizService.SetHydrating(true);
LogRequestingSummary(tenantId);
@@ -93,20 +94,21 @@ public sealed partial class KnowledgeCoordinator : IDisposable
await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
await _platformService.VibrateSuccessAsync();
return packet;
return Result.Ok(packet);
}
LogSummaryWarning(tenantId);
return Result.Fail(result.Errors);
}
catch (Exception ex)
{
LogSummaryError(ex, tenantId);
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
}
finally
{
await _quizService.SetHydrating(false);
}
return null;
}
public async Task ClearAsync()
@@ -2,13 +2,17 @@ using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Constants;
using NexusReader.Application.Constants;
namespace NexusReader.UI.Shared.Services;
public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly INativeStorageService _storageService;
// 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)
@@ -38,10 +42,15 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
{
_cachedState = CreateState(emailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "OpaqueBearer");
_cachedState = CreateState(
emailResult.Value,
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
"OpaqueBearer",
rolesResult.IsSuccess ? rolesResult.Value! : "");
return _cachedState;
}
}
@@ -51,7 +60,12 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
{
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
_cachedState = CreateState(storedEmailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "CookieAuth");
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
_cachedState = CreateState(
storedEmailResult.Value,
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
"CookieAuth",
rolesResult.IsSuccess ? rolesResult.Value! : "");
return _cachedState;
}
@@ -67,7 +81,7 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
}
}
private AuthenticationState CreateState(string email, string tenantId, string authType)
private AuthenticationState CreateState(string email, string tenantId, string authType, string rolesStr = "")
{
var claims = new List<Claim>
{
@@ -75,13 +89,23 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
new Claim(ClaimTypes.Email, email),
new Claim("TenantId", tenantId)
};
if (!string.IsNullOrEmpty(rolesStr))
{
var roles = rolesStr.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role.Trim()));
}
}
var identity = new ClaimsIdentity(claims, authType);
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void NotifyUserAuthentication(string email, string tenantId)
public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "")
{
_cachedState = CreateState(email, tenantId, "OpaqueBearer");
_cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr);
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
}
@@ -34,7 +34,7 @@ public class ReaderNavigationService : IReaderNavigationService
}
}
public void UpdateMetadata(int currentIndex, int totalChapters, string title)
public async Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title)
{
bool changed = false;
if (CurrentChapterIndex != currentIndex) { CurrentChapterIndex = currentIndex; changed = true; }
@@ -43,9 +43,7 @@ public class ReaderNavigationService : IReaderNavigationService
if (changed)
{
// Note: UpdateMetadata remains void, so we trigger notification fire-and-forget here
// but usually this is called during a render cycle where metadata is updated from a load.
_ = NotifyNavigationChangedAsync();
await NotifyNavigationChangedAsync();
}
}
@@ -13,45 +13,7 @@ public class WebStorageService : INativeStorageService
_jsRuntime = jsRuntime;
}
public Result SaveString(string key, string value)
{
try
{
_jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result<string?> GetString(string key)
{
return Result.Fail("Use GetStringAsync or similar if available, or call from async context.");
}
public Result SaveBool(string key, bool value) => SaveString(key, value.ToString());
public Result<bool> GetBool(string key, bool defaultValue = false)
{
return Result.Ok(defaultValue);
}
public Result Remove(string key)
{
try
{
_jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result> SaveSecureString(string key, string value)
public async Task<Result> SaveStringAsync(string key, string value)
{
try
{
@@ -64,7 +26,7 @@ public class WebStorageService : INativeStorageService
}
}
public async Task<Result<string?>> GetSecureString(string key)
public async Task<Result<string?>> GetStringAsync(string key)
{
try
{
@@ -77,8 +39,38 @@ public class WebStorageService : INativeStorageService
}
}
public Result RemoveSecure(string key)
public Task<Result> SaveBoolAsync(string key, bool value) => SaveStringAsync(key, value.ToString());
public async Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false)
{
return Remove(key);
try
{
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
if (string.IsNullOrEmpty(value)) return Result.Ok(defaultValue);
return Result.Ok(bool.TryParse(value, out var result) ? result : defaultValue);
}
catch
{
return Result.Ok(defaultValue);
}
}
public async Task<Result> RemoveAsync(string key)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result> SaveSecureString(string key, string value) => await SaveStringAsync(key, value);
public async Task<Result<string?>> GetSecureString(string key) => await GetStringAsync(key);
public Task<Result> RemoveSecureAsync(string key) => RemoveAsync(key);
}