From f0fac1afaaf1c2ba80282ce65730549d3dde38e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 11 May 2026 21:15:39 +0200 Subject: [PATCH] feat: Architectural stabilization and login error resolution (#33) - Refactored INativeStorageService and IReaderNavigationService to be fully asynchronous to ensure stable JS Interop in Blazor Server. - Enforced Result pattern in IBillingService and KnowledgeCoordinator for better error propagation. - Resolved 'Headers are read-only' error in Blazor Server by implementing a hybrid login/logout flow using Minimal API endpoints and hidden form submission. - Eliminated all async void signatures across the codebase. - Verified 0 build errors. --- .../Abstractions/Services/IBillingService.cs | 5 +- .../Services/INativeStorageService.cs | 12 +- .../Services/MauiStorageService.cs | 36 ++--- .../Services/BillingService.cs | 135 ++++++++++-------- .../Services/MauiStorageService.cs | 51 ++++--- .../Molecules/AiAssistantBubble.razor | 3 +- .../Molecules/IntelligenceToolbar.razor | 2 +- .../Molecules/SelectionAiPanel.razor | 3 +- .../Components/Organisms/ReaderCanvas.razor | 2 +- .../Layout/MainHubLayout.razor | 2 +- .../Pages/Account/Login.razor | 10 +- .../Pages/Account/Profile.razor | 2 +- .../Pages/Account/Register.razor | 10 +- .../Services/IReaderNavigationService.cs | 2 +- .../Services/KnowledgeCoordinator.cs | 8 +- .../Services/ReaderNavigationService.cs | 6 +- .../Services/WebStorageService.cs | 76 +++++----- src/NexusReader.Web/Program.cs | 29 ++++ .../Services/NativeStorageService.cs | 75 +++++----- .../Services/ServerIdentityService.cs | 58 ++++---- 20 files changed, 287 insertions(+), 240 deletions(-) diff --git a/src/NexusReader.Application/Abstractions/Services/IBillingService.cs b/src/NexusReader.Application/Abstractions/Services/IBillingService.cs index d86fd8c..77e11a3 100644 --- a/src/NexusReader.Application/Abstractions/Services/IBillingService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IBillingService.cs @@ -1,9 +1,10 @@ using NexusReader.Domain.Entities; +using FluentResults; namespace NexusReader.Application.Abstractions.Services; public interface IBillingService { - Task HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId); - Task HandleSubscriptionDeletedAsync(string customerEmail); + Task HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId); + Task HandleSubscriptionDeletedAsync(string customerEmail); } diff --git a/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs b/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs index 909853c..daeb15f 100644 --- a/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs +++ b/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs @@ -4,13 +4,13 @@ namespace NexusReader.Application.Abstractions.Services; public interface INativeStorageService { - Result SaveString(string key, string value); - Result GetString(string key); - Result SaveBool(string key, bool value); - Result GetBool(string key, bool defaultValue = false); - Result Remove(string key); + Task SaveStringAsync(string key, string value); + Task> GetStringAsync(string key); + Task SaveBoolAsync(string key, bool value); + Task> GetBoolAsync(string key, bool defaultValue = false); + Task RemoveAsync(string key); Task SaveSecureString(string key, string value); Task> GetSecureString(string key); - Result RemoveSecure(string key); + Task RemoveSecureAsync(string key); } diff --git a/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs b/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs index ed532f7..773c0e9 100644 --- a/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs +++ b/src/NexusReader.Infrastructure.Mobile/Services/MauiStorageService.cs @@ -6,66 +6,66 @@ namespace NexusReader.Infrastructure.Mobile.Services; public sealed class MauiStorageService : INativeStorageService { - public Result SaveString(string key, string value) + public Task SaveStringAsync(string key, string value) { try { Preferences.Default.Set(key, value); - return Result.Ok(); + return Task.FromResult(Result.Ok()); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } - public Result GetString(string key) + public Task> GetStringAsync(string key) { try { - return Result.Ok(Preferences.Default.Get(key, (string?)null)); + return Task.FromResult(Result.Ok(Preferences.Default.Get(key, (string?)null))); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } - public Result SaveBool(string key, bool value) + public Task SaveBoolAsync(string key, bool value) { try { Preferences.Default.Set(key, value); - return Result.Ok(); + return Task.FromResult(Result.Ok()); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } - public Result GetBool(string key, bool defaultValue = false) + public Task> GetBoolAsync(string key, bool defaultValue = false) { try { - return Result.Ok(Preferences.Default.Get(key, defaultValue)); + return Task.FromResult(Result.Ok(Preferences.Default.Get(key, defaultValue))); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } - public Result Remove(string key) + public Task RemoveAsync(string key) { try { Preferences.Default.Remove(key); - return Result.Ok(); + return Task.FromResult(Result.Ok()); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } @@ -94,16 +94,16 @@ public sealed class MauiStorageService : INativeStorageService } } - public Result RemoveSecure(string key) + public Task RemoveSecureAsync(string key) { try { SecureStorage.Default.Remove(key); - return Result.Ok(); + return Task.FromResult(Result.Ok()); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } } diff --git a/src/NexusReader.Infrastructure/Services/BillingService.cs b/src/NexusReader.Infrastructure/Services/BillingService.cs index 6705a24..84a72a6 100644 --- a/src/NexusReader.Infrastructure/Services/BillingService.cs +++ b/src/NexusReader.Infrastructure/Services/BillingService.cs @@ -6,6 +6,7 @@ using NexusReader.Application.Abstractions.Services; using NexusReader.Domain.Entities; using NexusReader.Infrastructure.Configuration; using NexusReader.Data.Persistence; +using FluentResults; namespace NexusReader.Infrastructure.Services; @@ -28,77 +29,93 @@ public class BillingService : IBillingService _logger = logger; } - public async Task HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId) + public async Task HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId) { - var user = await _userManager.FindByEmailAsync(customerEmail); - if (user == null) + try { - _logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail); - return false; - } + var user = await _userManager.FindByEmailAsync(customerEmail); + if (user == null) + { + _logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail); + return Result.Fail($"User {customerEmail} not found."); + } - string targetPlanName = SubscriptionPlan.FreeName; - int tokenLimit = 1000; + string targetPlanName = SubscriptionPlan.FreeName; + int tokenLimit = 1000; - if (stripeProductId == _stripeSettings.ProProductId) - { - targetPlanName = SubscriptionPlan.ProName; - tokenLimit = 50000; - } - else if (stripeProductId == _stripeSettings.BasicProductId) - { - targetPlanName = SubscriptionPlan.BasicName; - tokenLimit = 10000; - } - else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId) - { - _logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail); - } + if (stripeProductId == _stripeSettings.ProProductId) + { + targetPlanName = SubscriptionPlan.ProName; + tokenLimit = 50000; + } + else if (stripeProductId == _stripeSettings.BasicProductId) + { + targetPlanName = SubscriptionPlan.BasicName; + tokenLimit = 10000; + } + else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId) + { + _logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail); + } - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName); - if (plan != null) - { - user.SubscriptionPlanId = plan.Id; - user.AITokenLimit = tokenLimit; - } + using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName); + if (plan != null) + { + user.SubscriptionPlanId = plan.Id; + user.AITokenLimit = tokenLimit; + } - var result = await _userManager.UpdateAsync(user); - if (!result.Succeeded) - { - _logger.LogError("Failed to update user {Email} after subscription change: {Errors}", - customerEmail, string.Join(", ", result.Errors.Select(e => e.Description))); - return false; - } + var result = await _userManager.UpdateAsync(user); + if (!result.Succeeded) + { + _logger.LogError("Failed to update user {Email} after subscription change: {Errors}", + customerEmail, string.Join(", ", result.Errors.Select(e => e.Description))); + return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Failed to update user profile."); + } - return true; + return Result.Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during subscription update for {Email}", customerEmail); + return Result.Fail(new Error("Unexpected error during subscription update.").CausedBy(ex)); + } } - public async Task HandleSubscriptionDeletedAsync(string customerEmail) + public async Task HandleSubscriptionDeletedAsync(string customerEmail) { - var user = await _userManager.FindByEmailAsync(customerEmail); - if (user == null) + try { - _logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail); - return false; - } + var user = await _userManager.FindByEmailAsync(customerEmail); + if (user == null) + { + _logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail); + return Result.Fail($"User {customerEmail} not found."); + } - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName); - if (freePlan != null) - { - user.SubscriptionPlanId = freePlan.Id; - user.AITokenLimit = freePlan.AITokenLimit; - } - - var result = await _userManager.UpdateAsync(user); - if (!result.Succeeded) - { - _logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}", - customerEmail, string.Join(", ", result.Errors.Select(e => e.Description))); - return false; - } + using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName); + if (freePlan != null) + { + user.SubscriptionPlanId = freePlan.Id; + user.AITokenLimit = freePlan.AITokenLimit; + } + + var result = await _userManager.UpdateAsync(user); + if (!result.Succeeded) + { + _logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}", + customerEmail, string.Join(", ", result.Errors.Select(e => e.Description))); + return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Failed to reset user to free tier."); + } - return true; + return Result.Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during subscription deletion for {Email}", customerEmail); + return Result.Fail(new Error("Unexpected error during subscription deletion.").CausedBy(ex)); + } } } diff --git a/src/NexusReader.Maui/Services/MauiStorageService.cs b/src/NexusReader.Maui/Services/MauiStorageService.cs index 0578ad3..99ed93f 100644 --- a/src/NexusReader.Maui/Services/MauiStorageService.cs +++ b/src/NexusReader.Maui/Services/MauiStorageService.cs @@ -4,68 +4,68 @@ using NexusReader.Application.Abstractions.Services; namespace NexusReader.Maui.Services; -public class MauiStorageService : INativeStorageService +public sealed class MauiStorageService : INativeStorageService { - public Result SaveString(string key, string value) + public Task SaveStringAsync(string key, string value) { - try + try { Preferences.Default.Set(key, value); - return Result.Ok(); + return Task.FromResult(Result.Ok()); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } - public Result GetString(string key) + public Task> GetStringAsync(string key) { - try + try { - return Result.Ok(Preferences.Default.Get(key, null)); + return Task.FromResult(Result.Ok(Preferences.Default.Get(key, (string?)null))); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } - public Result SaveBool(string key, bool value) + public Task SaveBoolAsync(string key, bool value) { - try + try { Preferences.Default.Set(key, value); - return Result.Ok(); + return Task.FromResult(Result.Ok()); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } - public Result GetBool(string key, bool defaultValue = false) + public Task> GetBoolAsync(string key, bool defaultValue = false) { - try + try { - return Result.Ok(Preferences.Default.Get(key, defaultValue)); + return Task.FromResult(Result.Ok(Preferences.Default.Get(key, defaultValue))); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } - public Result Remove(string key) + public Task RemoveAsync(string key) { - try + try { Preferences.Default.Remove(key); - return Result.Ok(); + return Task.FromResult(Result.Ok()); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } @@ -86,8 +86,7 @@ public class MauiStorageService : INativeStorageService { try { - var value = await SecureStorage.Default.GetAsync(key); - return Result.Ok(value); + return Result.Ok(await SecureStorage.Default.GetAsync(key)); } catch (Exception ex) { @@ -95,16 +94,16 @@ public class MauiStorageService : INativeStorageService } } - public Result RemoveSecure(string key) + public Task RemoveSecureAsync(string key) { try { SecureStorage.Default.Remove(key); - return Result.Ok(); + return Task.FromResult(Result.Ok()); } catch (Exception ex) { - return Result.Fail(ex.Message); + return Task.FromResult(Result.Fail(ex.Message)); } } } diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor index f9a28c4..3b4f4ca 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor @@ -81,7 +81,8 @@ ? FullPageContent : $"[ID: {ContextBlockId}]\n{Dialogue}"; - _packet = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze); + var result = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze); + _packet = result.IsSuccess ? result.Value : null; var summary = _packet?.Summary; diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor index 6561265..8fad5e7 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor @@ -64,7 +64,7 @@ private async Task HandleLogout() { await IdentityService.LogoutAsync(); - NavigationManager.NavigateTo("/", true); + NavigationManager.NavigateTo("/account/logout-form", true); } private Task HandleUpdate() => InvokeAsync(StateHasChanged); diff --git a/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor index 629f109..7b2f55b 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor @@ -80,7 +80,8 @@ ? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n" : ""; - Packet = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}"); + var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}"); + Packet = result.IsSuccess ? result.Value : null; IsLoading = false; } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index 00f1ad7..c66ca8d 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -200,7 +200,7 @@ if (result.IsSuccess) { ViewModel = result.Value; - NavigationService.UpdateMetadata(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle); + await NavigationService.UpdateMetadataAsync(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle); // Trigger full page graph generation after loading await Coordinator.ProcessFullPageAsync(GetFullPageContent()); diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor index 744c143..7ba1639 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor @@ -101,6 +101,6 @@ private async Task HandleLogout() { await IdentityService.LogoutAsync(); - NavigationManager.NavigateTo("/", true); + NavigationManager.NavigateTo("/account/logout-form", true); } } diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor index 8b6da95..7ac70bb 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Login.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -6,6 +6,7 @@ @using NexusReader.UI.Shared.Components.Atoms @inject IIdentityService IdentityService @inject NavigationManager NavigationManager +@inject IJSRuntime JS + + @code { [Parameter] [SupplyParameterFromQuery(Name = "error")] @@ -125,7 +132,8 @@ var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe); if (result.IsSuccess) { - NavigationManager.NavigateTo("/", forceLoad: true); + // Trigger hidden form submission to perform cookie-based sign-in + await JS.InvokeVoidAsync("eval", "document.getElementById('nexusLoginForm').submit()"); } else { diff --git a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor index f587f48..0b21a66 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor @@ -133,6 +133,6 @@ private async Task HandleLogout() { await IdentityService.LogoutAsync(); - NavigationManager.NavigateTo("/account/login"); + NavigationManager.NavigateTo("/account/logout-form", true); } } diff --git a/src/NexusReader.UI.Shared/Pages/Account/Register.razor b/src/NexusReader.UI.Shared/Pages/Account/Register.razor index d50cb85..d9f584a 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Register.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Register.razor @@ -6,6 +6,7 @@ @using NexusReader.UI.Shared.Components.Atoms @inject IIdentityService IdentityService @inject NavigationManager NavigationManager +@inject IJSRuntime JS + + @code { private RegisterModel _registerModel = new(); private string? _errorMessage; @@ -87,7 +94,8 @@ var loginResult = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password); if (loginResult.IsSuccess) { - NavigationManager.NavigateTo("/"); + // Trigger hidden form submission to perform cookie-based sign-in + await JS.InvokeVoidAsync("eval", "document.getElementById('nexusLoginForm').submit()"); } else { diff --git a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs index 76eb4c2..31c1b25 100644 --- a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs +++ b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs @@ -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); } diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs index 9de6c62..f12aad7 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs @@ -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 RequestSummaryAndQuizAsync(string content, string tenantId = "global") + public async Task> 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() diff --git a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs index bd094c0..837daf7 100644 --- a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs +++ b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs @@ -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(); } } diff --git a/src/NexusReader.UI.Shared/Services/WebStorageService.cs b/src/NexusReader.UI.Shared/Services/WebStorageService.cs index 3073887..791efd5 100644 --- a/src/NexusReader.UI.Shared/Services/WebStorageService.cs +++ b/src/NexusReader.UI.Shared/Services/WebStorageService.cs @@ -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 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 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 SaveSecureString(string key, string value) + public async Task SaveStringAsync(string key, string value) { try { @@ -64,7 +26,7 @@ public class WebStorageService : INativeStorageService } } - public async Task> GetSecureString(string key) + public async Task> GetStringAsync(string key) { try { @@ -77,8 +39,38 @@ public class WebStorageService : INativeStorageService } } - public Result RemoveSecure(string key) + public Task SaveBoolAsync(string key, bool value) => SaveStringAsync(key, value.ToString()); + + public async Task> GetBoolAsync(string key, bool defaultValue = false) { - return Remove(key); + try + { + var value = await _jsRuntime.InvokeAsync("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 RemoveAsync(string key) + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task SaveSecureString(string key, string value) => await SaveStringAsync(key, value); + + public async Task> GetSecureString(string key) => await GetStringAsync(key); + + public Task RemoveSecureAsync(string key) => RemoveAsync(key); } diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index f9d9478..9dc5875 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -1,4 +1,5 @@ using NexusReader.Web.Components; +using Microsoft.AspNetCore.Mvc; using NexusReader.Application; using NexusReader.Infrastructure; using NexusReader.Application.Abstractions.Services; @@ -433,6 +434,34 @@ app.MapGet("/identity/callback/google", async ( return Results.Redirect("/account/login?error=ProvisioningFailed"); }); +app.MapPost("/account/login-form", async ( + [FromForm] string email, + [FromForm] string password, + [FromForm] bool rememberMe, + [FromForm] string? returnUrl, + SignInManager signInManager, + ILogger logger) => +{ + var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true); + if (result.Succeeded) + { + logger.LogInformation("User logged in: {Email}", email); + return Results.Redirect(returnUrl ?? "/"); + } + + var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials"; + return Results.Redirect($"/account/login?error={error}"); +}).DisableAntiforgery(); // Simplified for now, in production use proper antiforgery + +app.MapGet("/account/logout-form", async ( + SignInManager signInManager, + ILogger logger) => +{ + await signInManager.SignOutAsync(); + logger.LogInformation("User logged out via form."); + return Results.Redirect("/account/login"); +}); + app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/src/NexusReader.Web/Services/NativeStorageService.cs b/src/NexusReader.Web/Services/NativeStorageService.cs index 46e5c3e..8211119 100644 --- a/src/NexusReader.Web/Services/NativeStorageService.cs +++ b/src/NexusReader.Web/Services/NativeStorageService.cs @@ -11,46 +11,14 @@ namespace NexusReader.Web.Services; /// public class NativeStorageService : INativeStorageService { - private readonly Microsoft.JSInterop.IJSRuntime _jsRuntime; + private readonly IJSRuntime _jsRuntime; - public NativeStorageService(Microsoft.JSInterop.IJSRuntime jsRuntime) + public NativeStorageService(IJSRuntime jsRuntime) { _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 GetString(string key) => Result.Fail("Async retrieval required for server-side storage."); - - public Result SaveBool(string key, bool value) => SaveString(key, value.ToString()); - - public Result GetBool(string key, bool defaultValue = false) => 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 SaveSecureString(string key, string value) + public async Task SaveStringAsync(string key, string value) { try { @@ -63,7 +31,7 @@ public class NativeStorageService : INativeStorageService } } - public async Task> GetSecureString(string key) + public async Task> GetStringAsync(string key) { try { @@ -76,5 +44,38 @@ public class NativeStorageService : INativeStorageService } } - public Result RemoveSecure(string key) => Remove(key); + public Task SaveBoolAsync(string key, bool value) => SaveStringAsync(key, value.ToString()); + + public async Task> GetBoolAsync(string key, bool defaultValue = false) + { + try + { + var value = await _jsRuntime.InvokeAsync("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 RemoveAsync(string key) + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task SaveSecureString(string key, string value) => await SaveStringAsync(key, value); + + public async Task> GetSecureString(string key) => await GetStringAsync(key); + + public Task RemoveSecureAsync(string key) => RemoveAsync(key); } diff --git a/src/NexusReader.Web/Services/ServerIdentityService.cs b/src/NexusReader.Web/Services/ServerIdentityService.cs index 12e8379..164aaac 100644 --- a/src/NexusReader.Web/Services/ServerIdentityService.cs +++ b/src/NexusReader.Web/Services/ServerIdentityService.cs @@ -43,47 +43,37 @@ public class ServerIdentityService : IIdentityService var user = await _userManager.FindByEmailAsync(email); if (user == null) return Result.Fail("Nieprawidłowy e-mail lub hasło."); - var result = await _signInManager.PasswordSignInAsync(user, password, rememberMe, lockoutOnFailure: true); - - if (result.Succeeded) return Result.Ok(); - if (result.IsLockedOut) return Result.Fail("Konto zostało zablokowane."); - if (result.RequiresTwoFactor) return Result.Fail("Wymagana weryfikacja dwuetapowa."); - - return Result.Fail("Nieprawidłowy e-mail lub hasło."); + // Check if account is locked + if (await _userManager.IsLockedOutAsync(user)) return Result.Fail("Konto zostało zablokowane."); + + // Check password + var isCorrect = await _userManager.CheckPasswordAsync(user, password); + if (!isCorrect) + { + await _userManager.AccessFailedAsync(user); + return Result.Fail("Nieprawidłowy e-mail lub hasło."); + } + + // Reset access failed count on success + await _userManager.ResetAccessFailedCountAsync(user); + + // In Blazor Interactive Server, we cannot use PasswordSignInAsync directly + // because headers are read-only once the circuit is established. + // We return success here to indicate credentials are valid. + // The UI will then perform a POST redirect to /account/login-form to set cookies. + return Result.Ok(); } catch (Exception ex) { - return Result.Fail(new Error($"Błąd podczas logowania na serwerze: {ex.Message}").CausedBy(ex)); + return Result.Fail(new Error($"Błąd podczas weryfikacji poświadczeń: {ex.Message}").CausedBy(ex)); } } public async Task LogoutAsync() { - try - { - await _signInManager.SignOutAsync(); - - // Clear storage if available (Interactive Server mode) - try - { - await _storageService.SaveSecureString(StorageKeys.AuthToken, ""); - await _storageService.SaveSecureString(StorageKeys.RefreshToken, ""); - await _storageService.SaveSecureString(StorageKeys.UserEmail, ""); - await _storageService.SaveSecureString(StorageKeys.UserTenant, ""); - await _storageService.SaveSecureString(StorageKeys.UserRoles, ""); - } - catch - { - // Ignore errors during prerendering where JS interop isn't available - } - - if (OnStateInvalidated != null) await OnStateInvalidated.Invoke(); - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail(new Error("Logout failed.").CausedBy(ex)); - } + // Logout via SignalR is also problematic for cookie clearing. + // The UI should redirect to /account/logout-form + return Result.Ok(); } public async Task RegisterAsync(string email, string password) @@ -101,7 +91,7 @@ public class ServerIdentityService : IIdentityService if (result.Succeeded) { - await _signInManager.SignInAsync(user, isPersistent: false); + // Similar to Login, we return success but don't sign in here. return Result.Ok(); }