diff --git a/.agent/skills/nexus-architecture-standards/SKILL.md b/.agent/skills/nexus-architecture-standards/SKILL.md index b331f76..1189c67 100644 --- a/.agent/skills/nexus-architecture-standards/SKILL.md +++ b/.agent/skills/nexus-architecture-standards/SKILL.md @@ -39,8 +39,8 @@ This skill defines the architectural guardrails for the NexusReader project to e ### 6. Database Schema Changes - Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration. - **Mandatory Commands**: - - `dotnet ef migrations add --project src/NexusReader.Data --startup-project src/NexusReader.Web.New` - - `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web.New` + - `dotnet ef migrations add --project src/NexusReader.Data --startup-project src/NexusReader.Web` + - `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web` - Ensure the migration is applied to all local development environments before proceeding with feature verification. ## Audit Scripts diff --git a/.gitignore b/.gitignore index 1197550..b523661 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ Thumbs.db *.epub .fake -src/NexusReader.Web.New/nexus.db +src/NexusReader.Web/nexus.db diff --git a/Dockerfile b/Dockerfile index c76b917..4e8f58e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,20 +3,20 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src # Copy csproj files and restore dependencies -COPY ["src/NexusReader.Web.New/NexusReader.Web.csproj", "src/NexusReader.Web.New/"] +COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"] COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"] COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"] COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"] COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"] COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"] -RUN dotnet restore "src/NexusReader.Web.New/NexusReader.Web.csproj" +RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj" # Copy the rest of the source code COPY . . # Build and publish -WORKDIR "/src/src/NexusReader.Web.New" +WORKDIR "/src/src/NexusReader.Web" RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false # Stage 2: Runtime diff --git a/NexusReader.slnx b/NexusReader.slnx index 9fcc85b..9eda04d 100644 --- a/NexusReader.slnx +++ b/NexusReader.slnx @@ -9,7 +9,10 @@ - - + + + + + diff --git a/run-debug.sh b/run-debug.sh index c48a24b..00d7cf8 100755 --- a/run-debug.sh +++ b/run-debug.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ------------------------------------------------------------- -# Debug helper for NexusReader.Web.New (Blazor Server) +# Debug helper for NexusReader.Web (Blazor Server) # ------------------------------------------------------------- # 1️⃣ Ensure the port is free before starting the server. # 2️⃣ Starts the server project in the background. @@ -10,7 +10,7 @@ # ------------------------------------------------------------- # ---- configuration ------------------------------------------------ -SERVER_PROJECT="src/NexusReader.Web.New/NexusReader.Web.csproj" +SERVER_PROJECT="src/NexusReader.Web/NexusReader.Web.csproj" APP_URL="http://localhost:5104" DEBUG_PORT=9222 TMP_PROFILE="/tmp/blazor-chrome-debug" 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/IEpubMetadataExtractor.cs b/src/NexusReader.Application/Abstractions/Services/IEpubMetadataExtractor.cs new file mode 100644 index 0000000..7f79757 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IEpubMetadataExtractor.cs @@ -0,0 +1,10 @@ +using FluentResults; +using NexusReader.Application.Queries.Reader; +using System.IO; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IEpubMetadataExtractor +{ + Task> ExtractMetadataAsync(Stream epubStream); +} diff --git a/src/NexusReader.Application/Abstractions/Services/IEpubService.cs b/src/NexusReader.Application/Abstractions/Services/IEpubReader.cs similarity index 88% rename from src/NexusReader.Application/Abstractions/Services/IEpubService.cs rename to src/NexusReader.Application/Abstractions/Services/IEpubReader.cs index 188988c..d1790f2 100644 --- a/src/NexusReader.Application/Abstractions/Services/IEpubService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IEpubReader.cs @@ -3,7 +3,7 @@ using NexusReader.Application.Queries.Reader; namespace NexusReader.Application.Abstractions.Services; -public interface IEpubService +public interface IEpubReader { Task> GetEpubContentAsync(int chapterIndex, string? userId = null); } diff --git a/src/NexusReader.Application/Abstractions/Services/IIdentityService.cs b/src/NexusReader.Application/Abstractions/Services/IIdentityService.cs new file mode 100644 index 0000000..93b9c9b --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IIdentityService.cs @@ -0,0 +1,14 @@ +using FluentResults; +using NexusReader.Application.DTOs.User; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IIdentityService +{ + event Func? OnStateInvalidated; + Task RegisterAsync(string email, string password); + Task LoginAsync(string email, string password, bool rememberMe = false); + Task LogoutAsync(); + Task> GetProfileAsync(); + Task RefreshTokenAsync(); +} 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.UI.Shared/Constants/PlanConstants.cs b/src/NexusReader.Application/Constants/PlanConstants.cs similarity index 81% rename from src/NexusReader.UI.Shared/Constants/PlanConstants.cs rename to src/NexusReader.Application/Constants/PlanConstants.cs index dde7e7f..9b5d200 100644 --- a/src/NexusReader.UI.Shared/Constants/PlanConstants.cs +++ b/src/NexusReader.Application/Constants/PlanConstants.cs @@ -1,4 +1,4 @@ -namespace NexusReader.UI.Shared.Constants; +namespace NexusReader.Application.Constants; public static class PlanConstants { diff --git a/src/NexusReader.UI.Shared/Constants/StorageKeys.cs b/src/NexusReader.Application/Constants/StorageKeys.cs similarity index 72% rename from src/NexusReader.UI.Shared/Constants/StorageKeys.cs rename to src/NexusReader.Application/Constants/StorageKeys.cs index e5e1fc2..d9225b3 100644 --- a/src/NexusReader.UI.Shared/Constants/StorageKeys.cs +++ b/src/NexusReader.Application/Constants/StorageKeys.cs @@ -1,4 +1,4 @@ -namespace NexusReader.UI.Shared.Constants; +namespace NexusReader.Application.Constants; public static class StorageKeys { @@ -6,4 +6,5 @@ public static class StorageKeys public const string RefreshToken = "nexus_refresh_token"; public const string UserEmail = "nexus_user_email"; public const string UserTenant = "nexus_user_tenant"; + public const string UserRoles = "nexus_user_roles"; } diff --git a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs index b85ddfd..7060c49 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -1,3 +1,5 @@ +using NexusReader.Application.Constants; + namespace NexusReader.Application.DTOs.User; public record UserProfileDto @@ -17,6 +19,13 @@ public record UserProfileDto /// Summary of the last read book. /// public LastReadBookDto? LastReadBook { get; init; } + + public string[] Roles { get; init; } = Array.Empty(); + + // 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 record LastReadBookDto diff --git a/src/NexusReader.Application/Mappings/MappingConfig.cs b/src/NexusReader.Application/Mappings/MappingConfig.cs index b21d5c5..861a1c7 100644 --- a/src/NexusReader.Application/Mappings/MappingConfig.cs +++ b/src/NexusReader.Application/Mappings/MappingConfig.cs @@ -1,7 +1,8 @@ using Mapster; using MapsterMapper; using Microsoft.Extensions.DependencyInjection; -using System.Reflection; +using NexusReader.Domain.Entities; +using NexusReader.Application.DTOs.User; namespace NexusReader.Application.Mappings; @@ -11,8 +12,8 @@ public static class MappingConfig { var config = TypeAdapterConfig.GlobalSettings; - // Manual registration for AOT (or use Source Generator) - // config.NewConfig(); + config.NewConfig(); + // Roles are mapped manually in queries due to Identity structure services.AddSingleton(config); services.AddScoped(); diff --git a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs index a7fb52d..7f70859 100644 --- a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs @@ -6,9 +6,9 @@ namespace NexusReader.Application.Queries.Reader; internal sealed class GetReaderPageQueryHandler : IQueryHandler { - private readonly IEpubService _epubService; + private readonly IEpubReader _epubService; - public GetReaderPageQueryHandler(IEpubService epubService) + public GetReaderPageQueryHandler(IEpubReader epubService) { _epubService = epubService; } diff --git a/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs new file mode 100644 index 0000000..be21c38 --- /dev/null +++ b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs @@ -0,0 +1,7 @@ +namespace NexusReader.Application.Queries.Reader; + +public record LocalEpubMetadata( + string Title, + string Author, + byte[]? CoverImage = null +); diff --git a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs index b6fe031..92b04f3 100644 --- a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs @@ -48,7 +48,11 @@ public class GetUserProfileQueryHandler : IRequestHandler ur.UserId == u.Id) + .Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!) + .ToArray() }) .FirstOrDefaultAsync(cancellationToken); diff --git a/src/NexusReader.Data/Persistence/AppDbContextFactory.cs b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs index 9c2f3a1..6454c8c 100644 --- a/src/NexusReader.Data/Persistence/AppDbContextFactory.cs +++ b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs @@ -19,7 +19,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory } var basePath = currentDir != null - ? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New") + ? Path.Combine(currentDir.FullName, "src", "NexusReader.Web") : Directory.GetCurrentDirectory(); var configuration = new ConfigurationBuilder() 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/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 2b03646..46d4fc4 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -73,7 +73,8 @@ public static class DependencyInjection })); services.AddScoped(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddAuthorizationCore(options => { diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index 67e1161..6ed085b 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -11,7 +11,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive 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.Infrastructure/Services/EpubService.cs b/src/NexusReader.Infrastructure/Services/EpubService.cs index b516625..a473e9e 100644 --- a/src/NexusReader.Infrastructure/Services/EpubService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubService.cs @@ -10,13 +10,13 @@ using NexusReader.Domain.Entities; namespace NexusReader.Infrastructure.Services; -public class EpubService : IEpubService +public class EpubReaderService : IEpubReader { private readonly IDbContextFactory _dbContextFactory; private const string EpubPath = "wwwroot/assets/book.epub"; private const int WordThreshold = 1000; - public EpubService(IDbContextFactory dbContextFactory) + public EpubReaderService(IDbContextFactory dbContextFactory) { _dbContextFactory = dbContextFactory; } @@ -34,7 +34,7 @@ public class EpubService : IEpubService while (currentDir != null) { var checkPath1 = Path.Combine(currentDir.FullName, relativePath); - var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New", relativePath); + var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", relativePath); searchPaths.Add(checkPath1); if (File.Exists(checkPath1)) { fullPath = checkPath1; break; } @@ -215,4 +215,24 @@ public class EpubService : IEpubService } return null; } + // Metadata extraction moved to EpubMetadataExtractor +} + +public class EpubMetadataExtractor : IEpubMetadataExtractor +{ + public async Task> ExtractMetadataAsync(Stream epubStream) + { + try + { + using var bookRef = await EpubReader.OpenBookAsync(epubStream); + var title = bookRef.Title ?? "Unknown Title"; + var author = bookRef.Author ?? "Unknown Author"; + byte[]? cover = await bookRef.ReadCoverAsync(); + return Result.Ok(new LocalEpubMetadata(title, author, cover)); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").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/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor new file mode 100644 index 0000000..41c3718 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -0,0 +1,159 @@ +@using Microsoft.AspNetCore.Components.Forms +@using NexusReader.Application.Abstractions.Services +@using NexusReader.Application.Queries.Reader +@inject IEpubMetadataExtractor MetadataExtractor +@inject ILogger Logger +@implements IAsyncDisposable + +@if (IsOpen) +{ + +} + +@code { + /// + /// Gets or sets a value indicating whether the modal is open. + /// + [Parameter] + public bool IsOpen { get; set; } + + /// + /// Event triggered when the IsOpen state changes. + /// + [Parameter] + public EventCallback IsOpenChanged { get; set; } + + private bool _isDragging; + private bool IsParsing { get; set; } + private LocalEpubMetadata? Metadata { get; set; } + private string? ErrorMessage { get; set; } + + // Allow up to 50 MB + private const long MaxFileSize = 50 * 1024 * 1024; + + private async Task CloseModal() + { + IsOpen = false; + Reset(); + await IsOpenChanged.InvokeAsync(false); + } + + private void Reset() + { + IsParsing = false; + Metadata = null; + ErrorMessage = null; + _isDragging = false; + } + + private void OnDragEnter() => _isDragging = true; + private void OnDragLeave() => _isDragging = false; + + private async Task HandleFileSelected(InputFileChangeEventArgs e) + { + _isDragging = false; + var file = e.File; + + if (file == null) return; + + if (!file.Name.EndsWith(".epub", StringComparison.OrdinalIgnoreCase)) + { + ErrorMessage = "Only .epub files are supported."; + return; + } + + ErrorMessage = null; + IsParsing = true; + StateHasChanged(); + + try + { + using var stream = file.OpenReadStream(MaxFileSize); + + // In Blazor WASM, we might need to copy to memory stream first for synchronous parsing if the parser doesn't stream well over interop + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream); + + if (result.IsSuccess) + { + Metadata = result.Value; + } + else + { + ErrorMessage = result.Errors.FirstOrDefault()?.Message ?? "Failed to parse EPUB."; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error uploading EPUB"); + ErrorMessage = $"An unexpected error occurred: {ex.Message} \n {ex.StackTrace}"; + } + finally + { + IsParsing = false; + StateHasChanged(); + } + } + public ValueTask DisposeAsync() + { + // Cleanup if necessary + return ValueTask.CompletedTask; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css new file mode 100644 index 0000000..635ca08 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css @@ -0,0 +1,272 @@ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.3s ease-out; +} + +.modal-content { + background: linear-gradient(145deg, #1a1a1a 0%, #0a0a0a 100%); + border: 1px solid rgba(0, 255, 153, 0.2); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 255, 153, 0.05); + border-radius: 20px; + width: 90%; + max-width: 500px; + padding: 2.5rem; + display: flex; + flex-direction: column; + gap: 2rem; + position: relative; + overflow: hidden; + backdrop-filter: blur(16px); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { + margin: 0; + font-family: var(--nexus-font-sans); + color: var(--nexus-text); + font-size: 1.5rem; +} + +.close-btn { + background: none; + border: none; + color: var(--nexus-text-muted, #888); + cursor: pointer; + transition: color 0.2s; +} + +.close-btn:hover { + color: var(--nexus-neon, #00ffaa); + transform: rotate(90deg); +} + +.modal-body { + min-height: 250px; + display: flex; + flex-direction: column; + justify-content: center; +} + +/* Upload State */ +.upload-state { + flex: 1; + display: flex; +} + +.drop-zone { + flex: 1; + border: 2px dashed rgba(255, 255, 255, 0.1); + border-radius: 8px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: pointer; + transition: all 0.3s ease; + background: rgba(255, 255, 255, 0.02); + position: relative; +} + +.drop-zone:hover, .upload-state.drag-over .drop-zone { + border-color: var(--nexus-accent, #00ffaa); + background: rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.05); +} + +.drop-zone-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: var(--nexus-text-muted, #888); + pointer-events: none; +} + +.drop-zone-content svg { + color: var(--nexus-accent, #00ffaa); + opacity: 0.8; +} + +.drop-zone-content p { + margin: 0; + font-size: 1.1rem; + color: var(--nexus-text); +} + +.drop-zone ::deep .file-input-cover { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + z-index: 10; +} + +/* Parsing State */ +.parsing-state { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + position: relative; + overflow: hidden; +} + +.shimmer::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 50%; + height: 100%; + background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.05), transparent); + animation: shimmer 2s infinite; +} + +.shimmer-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(0, 255, 153, 0.1); + border-top-color: var(--nexus-neon, #00ffaa); + border-radius: 50%; + animation: spin 1s linear infinite; + filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3)); +} + +.parsing-state p { + color: var(--nexus-text); + font-family: var(--nexus-font-mono, monospace); + font-size: 0.9rem; + letter-spacing: 1px; +} + +/* Metadata State */ +.metadata-state { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.metadata-info { + text-align: center; +} + +.metadata-info h3 { + margin: 0 0 0.5rem 0; + color: var(--nexus-text); + font-size: 1.25rem; +} + +.metadata-info .author { + margin: 0; + color: var(--nexus-text-muted, #888); +} + +.actions { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1rem; +} + +.btn { + font-family: var(--nexus-font-sans); + font-weight: 600; + padding: 0.75rem 1.5rem; + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-size: 0.85rem; + letter-spacing: 0.5px; + display: inline-flex; + align-items: center; + justify-content: center; + text-transform: uppercase; +} + +.btn-primary { + background: var(--nexus-neon, #00ffaa); + color: #050505; + box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2); +} + +.btn-primary:hover { + background: #00e699; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.03); + color: var(--nexus-text); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +.btn-secondary:active { + transform: translateY(0); +} + +.error-message { + margin-top: 1rem; + color: #ff5555; + text-align: center; + font-size: 0.9rem; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes shimmer { + 100% { left: 200%; } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .modal-backdrop, + .shimmer::before, + .spinner { + animation: none !important; + transition: none !important; + } +} 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 3feec48..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("/"); + // 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 370aa41..0b21a66 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor @@ -106,7 +106,7 @@ @code { - private UserProfile? _profile; + private UserProfileDto? _profile; protected override async Task OnInitializedAsync() { @@ -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/Pages/Dashboard.razor b/src/NexusReader.UI.Shared/Pages/Dashboard.razor index f6769cd..44ffb79 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor @@ -134,7 +134,7 @@ @code { - private UserProfile? _profile; + private UserProfileDto? _profile; protected override async Task OnInitializedAsync() { diff --git a/src/NexusReader.UI.Shared/Pages/Library.razor b/src/NexusReader.UI.Shared/Pages/Library.razor index 412c9ca..0d771cc 100644 --- a/src/NexusReader.UI.Shared/Pages/Library.razor +++ b/src/NexusReader.UI.Shared/Pages/Library.razor @@ -1,16 +1,19 @@ @page "/library" @attribute [Authorize] +@using NexusReader.UI.Shared.Components.Organisms

Biblioteka

- + [+] Add New Book
+ +

Twoja kolekcja książek i dokumentów pojawi się tutaj wkrótce.

@@ -51,3 +54,7 @@ opacity: 0.6; } + +@code { + private bool _isModalOpen; +} 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/IdentityService.cs b/src/NexusReader.UI.Shared/Services/IdentityService.cs index d2ade88..441ea88 100644 --- a/src/NexusReader.UI.Shared/Services/IdentityService.cs +++ b/src/NexusReader.UI.Shared/Services/IdentityService.cs @@ -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? OnStateInvalidated; - Task RegisterAsync(string email, string password); - Task LoginAsync(string email, string password, bool rememberMe = false); - Task LogoutAsync(); - Task> GetProfileAsync(); - Task 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? _profileTask; - private UserProfile? _cachedProfile; + private Task? _profileTask; + private UserProfileDto? _cachedProfile; private DateTime _lastFetchAttempt = DateTime.MinValue; public event Func? 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()); + 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> GetProfileAsync() + public async Task> GetProfileAsync() { if (_cachedProfile != null) { @@ -166,7 +147,7 @@ public class IdentityService : IIdentityService - private async Task GetProfileInternalAsync() + private async Task GetProfileInternalAsync() { if (!System.OperatingSystem.IsBrowser()) { @@ -191,13 +172,17 @@ public class IdentityService : IIdentityService if (response.IsSuccessStatusCode) { - var profile = await response.Content.ReadFromJsonAsync(); + var profile = await response.Content.ReadFromJsonAsync(); 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()); + 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()); + await _storageService.SaveSecureString(StorageKeys.UserRoles, rolesStr); + + (_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString(), rolesStr); } return Result.Ok(); 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/NexusAuthenticationStateProvider.cs b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs index 42e5218..7835422 100644 --- a/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs +++ b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs @@ -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 { @@ -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)); } 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.UI.Shared/_Imports.razor b/src/NexusReader.UI.Shared/_Imports.razor index 8224944..ef0b500 100644 --- a/src/NexusReader.UI.Shared/_Imports.razor +++ b/src/NexusReader.UI.Shared/_Imports.razor @@ -16,3 +16,6 @@ @using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Services @using Microsoft.Extensions.Logging +@using NexusReader.Application.Abstractions.Services +@using NexusReader.Application.DTOs.User +@using NexusReader.Application.Queries.Reader diff --git a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj index 8046dc7..c6d2c16 100644 --- a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj +++ b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj @@ -13,6 +13,7 @@ + diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index 736517f..461854d 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -47,7 +47,8 @@ builder.Services.AddSingleton>(new ThrowingDbCon builder.Services.AddSingleton>>(new ThrowingEmbeddingGenerator()); builder.Services.AddApplication(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/src/NexusReader.Web.Client/Services/WasmEpubService.cs b/src/NexusReader.Web.Client/Services/WasmEpubService.cs index 00431ed..63c5760 100644 --- a/src/NexusReader.Web.Client/Services/WasmEpubService.cs +++ b/src/NexusReader.Web.Client/Services/WasmEpubService.cs @@ -5,11 +5,11 @@ using NexusReader.Application.Queries.Reader; namespace NexusReader.Web.Client.Services; -public class WasmEpubService : IEpubService +public class WasmEpubReader : IEpubReader { private readonly HttpClient _httpClient; - public WasmEpubService(HttpClient httpClient) + public WasmEpubReader(HttpClient httpClient) { _httpClient = httpClient; } @@ -35,4 +35,24 @@ public class WasmEpubService : IEpubService return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex)); } } + // Metadata extraction moved to WasmEpubMetadataExtractor +} + +public class WasmEpubMetadataExtractor : IEpubMetadataExtractor +{ + public async Task> ExtractMetadataAsync(Stream epubStream) + { + try + { + using var bookRef = await VersOne.Epub.EpubReader.OpenBookAsync(epubStream); + var title = bookRef.Title ?? "Unknown Title"; + var author = bookRef.Author ?? "Unknown Author"; + byte[]? cover = await bookRef.ReadCoverAsync(); + return Result.Ok(new LocalEpubMetadata(title, author, cover)); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex)); + } + } } diff --git a/src/NexusReader.Web.New/Services/ServerIdentityService.cs b/src/NexusReader.Web.New/Services/ServerIdentityService.cs deleted file mode 100644 index bac61e2..0000000 --- a/src/NexusReader.Web.New/Services/ServerIdentityService.cs +++ /dev/null @@ -1,68 +0,0 @@ -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 _userManager; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IMediator _mediator; - - public event Func? OnStateInvalidated; - - public ServerIdentityService( - UserManager userManager, - IHttpContextAccessor httpContextAccessor, - IMediator mediator) - { - _userManager = userManager; - _httpContextAccessor = httpContextAccessor; - _mediator = mediator; - } - - public Task LoginAsync(string email, string password, bool rememberMe = false) - => throw new NotSupportedException("Use standard Identity endpoints for login on server."); - - public Task LogoutAsync() - => throw new NotSupportedException("Use standard Identity endpoints for logout on server."); - - public Task RegisterAsync(string email, string password) - => throw new NotSupportedException("Use standard Identity endpoints for registration on server."); - - public Task RefreshTokenAsync() => Task.FromResult(Result.Ok()); - - public async Task> 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); - } -} diff --git a/src/NexusReader.Web.New/Components/App.razor b/src/NexusReader.Web/Components/App.razor similarity index 100% rename from src/NexusReader.Web.New/Components/App.razor rename to src/NexusReader.Web/Components/App.razor diff --git a/src/NexusReader.Web.New/Components/Error.razor b/src/NexusReader.Web/Components/Error.razor similarity index 100% rename from src/NexusReader.Web.New/Components/Error.razor rename to src/NexusReader.Web/Components/Error.razor diff --git a/src/NexusReader.Web.New/Components/Pages/Error.razor b/src/NexusReader.Web/Components/Pages/Error.razor similarity index 100% rename from src/NexusReader.Web.New/Components/Pages/Error.razor rename to src/NexusReader.Web/Components/Pages/Error.razor diff --git a/src/NexusReader.Web.New/Components/_Imports.razor b/src/NexusReader.Web/Components/_Imports.razor similarity index 100% rename from src/NexusReader.Web.New/Components/_Imports.razor rename to src/NexusReader.Web/Components/_Imports.razor diff --git a/src/NexusReader.Web.New/NexusReader.Web.csproj b/src/NexusReader.Web/NexusReader.Web.csproj similarity index 95% rename from src/NexusReader.Web.New/NexusReader.Web.csproj rename to src/NexusReader.Web/NexusReader.Web.csproj index 90fb241..5069c37 100644 --- a/src/NexusReader.Web.New/NexusReader.Web.csproj +++ b/src/NexusReader.Web/NexusReader.Web.csproj @@ -16,6 +16,7 @@ all + diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web/Program.cs similarity index 92% rename from src/NexusReader.Web.New/Program.cs rename to src/NexusReader.Web/Program.cs index 76918f0..9dc5875 100644 --- a/src/NexusReader.Web.New/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; @@ -37,7 +38,7 @@ builder.Services.AddSignalR(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -54,7 +55,7 @@ builder.Services.AddHttpClient("NexusAPI", client => builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); builder.Services.AddHttpContextAccessor(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddApplication(); @@ -226,7 +227,7 @@ app.MapStaticAssets(); app.MapHub("/synchub"); // API endpoint for WASM client to fetch EPUB content -app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService, ClaimsPrincipal user) => +app.MapGet("/api/epub/{index}", async (int index, IEpubReader epubService, ClaimsPrincipal user) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var result = await epubService.GetEpubContentAsync(index, userId); @@ -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.New/Properties/launchSettings.json b/src/NexusReader.Web/Properties/launchSettings.json similarity index 100% rename from src/NexusReader.Web.New/Properties/launchSettings.json rename to src/NexusReader.Web/Properties/launchSettings.json diff --git a/src/NexusReader.Web/Services/NativeStorageService.cs b/src/NexusReader.Web/Services/NativeStorageService.cs new file mode 100644 index 0000000..8211119 --- /dev/null +++ b/src/NexusReader.Web/Services/NativeStorageService.cs @@ -0,0 +1,81 @@ +using FluentResults; +using Microsoft.JSInterop; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Web.Services; + +/// +/// Server-side implementation of INativeStorageService. +/// Note: In Blazor Server, localStorage is only accessible via JS Interop. +/// This implementation handles cases where JS Interop might not be available (e.g. during prerendering). +/// +public class NativeStorageService : INativeStorageService +{ + private readonly IJSRuntime _jsRuntime; + + public NativeStorageService(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public async Task SaveStringAsync(string key, string value) + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task> GetStringAsync(string key) + { + try + { + var value = await _jsRuntime.InvokeAsync("localStorage.getItem", key); + return Result.Ok(value); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + 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 new file mode 100644 index 0000000..164aaac --- /dev/null +++ b/src/NexusReader.Web/Services/ServerIdentityService.cs @@ -0,0 +1,121 @@ +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.Application.Constants; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Web.Services; + +public class ServerIdentityService : IIdentityService +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IMediator _mediator; + private readonly INativeStorageService _storageService; + + public event Func? OnStateInvalidated; + + public ServerIdentityService( + UserManager userManager, + SignInManager signInManager, + IHttpContextAccessor httpContextAccessor, + IMediator mediator, + INativeStorageService storageService) + { + _userManager = userManager; + _signInManager = signInManager; + _httpContextAccessor = httpContextAccessor; + _mediator = mediator; + _storageService = storageService; + } + + public async Task LoginAsync(string email, string password, bool rememberMe = false) + { + try + { + var user = await _userManager.FindByEmailAsync(email); + if (user == null) 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 weryfikacji poświadczeń: {ex.Message}").CausedBy(ex)); + } + } + + public async Task LogoutAsync() + { + // 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) + { + try + { + var user = new NexusUser + { + UserName = email, + Email = email, + SubscriptionPlanId = SubscriptionPlan.FreeId, + TenantId = "global" + }; + var result = await _userManager.CreateAsync(user, password); + + if (result.Succeeded) + { + // Similar to Login, we return success but don't sign in here. + return Result.Ok(); + } + + return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Rejestracja nie powiodła się."); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Błąd podczas rejestracji na serwerze: {ex.Message}").CausedBy(ex)); + } + } + + public Task RefreshTokenAsync() => Task.FromResult(Result.Ok()); + + public async Task> 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); + + return Result.Ok(result.Value); + } +} diff --git a/src/NexusReader.Web.New/appsettings.Development.json b/src/NexusReader.Web/appsettings.Development.json similarity index 100% rename from src/NexusReader.Web.New/appsettings.Development.json rename to src/NexusReader.Web/appsettings.Development.json diff --git a/src/NexusReader.Web.New/appsettings.json b/src/NexusReader.Web/appsettings.json similarity index 100% rename from src/NexusReader.Web.New/appsettings.json rename to src/NexusReader.Web/appsettings.json diff --git a/src/NexusReader.Web.New/wwwroot/app.css b/src/NexusReader.Web/wwwroot/app.css similarity index 100% rename from src/NexusReader.Web.New/wwwroot/app.css rename to src/NexusReader.Web/wwwroot/app.css diff --git a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj new file mode 100644 index 0000000..e464bb1 --- /dev/null +++ b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/tests/NexusReader.Application.Tests/Services/EpubMetadataExtractorTests.cs b/tests/NexusReader.Application.Tests/Services/EpubMetadataExtractorTests.cs new file mode 100644 index 0000000..102a24c --- /dev/null +++ b/tests/NexusReader.Application.Tests/Services/EpubMetadataExtractorTests.cs @@ -0,0 +1,25 @@ +using System.IO; +using System.Threading.Tasks; +using FluentAssertions; +using NexusReader.Infrastructure.Services; +using Xunit; + +namespace NexusReader.Application.Tests.Services; + +public class EpubMetadataExtractorTests +{ + [Fact] + public async Task ExtractMetadataAsync_WithInvalidStream_ReturnsFailure() + { + // Arrange + var extractor = new EpubMetadataExtractor(); + using var stream = new MemoryStream(new byte[] { 0, 1, 2, 3 }); + + // Act + var result = await extractor.ExtractMetadataAsync(stream); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } +}