fe5ff81c98
## 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>
119 lines
4.8 KiB
C#
119 lines
4.8 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using NexusReader.Application.Abstractions.Services;
|
|
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)
|
|
{
|
|
_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);
|
|
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
|
|
|
|
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
|
{
|
|
_cachedState = CreateState(
|
|
emailResult.Value,
|
|
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
|
|
"OpaqueBearer",
|
|
rolesResult.IsSuccess ? rolesResult.Value! : "");
|
|
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);
|
|
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
|
|
_cachedState = CreateState(
|
|
storedEmailResult.Value,
|
|
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
|
|
"CookieAuth",
|
|
rolesResult.IsSuccess ? rolesResult.Value! : "");
|
|
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, string rolesStr = "")
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new Claim(ClaimTypes.Name, email),
|
|
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, string rolesStr = "")
|
|
{
|
|
_cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr);
|
|
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
|
}
|
|
|
|
public void NotifyUserLogout()
|
|
{
|
|
_cachedState = null;
|
|
var guest = new ClaimsPrincipal(new ClaimsIdentity());
|
|
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
|
|
}
|
|
}
|