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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user