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 4e4dfc3..9eda04d 100644 --- a/NexusReader.slnx +++ b/NexusReader.slnx @@ -1,16 +1,18 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + 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/IEpubMetadataExtractor.cs b/src/NexusReader.Application/Abstractions/Services/IEpubMetadataExtractor.cs index 8a749a9..7f79757 100644 --- a/src/NexusReader.Application/Abstractions/Services/IEpubMetadataExtractor.cs +++ b/src/NexusReader.Application/Abstractions/Services/IEpubMetadataExtractor.cs @@ -1,5 +1,6 @@ using FluentResults; using NexusReader.Application.Queries.Reader; +using System.IO; namespace NexusReader.Application.Abstractions.Services; diff --git a/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs b/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs index 5b157a2..909853c 100644 --- a/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs +++ b/src/NexusReader.Application/Abstractions/Services/INativeStorageService.cs @@ -4,11 +4,13 @@ namespace NexusReader.Application.Abstractions.Services; public interface INativeStorageService { - Task> GetSecureString(string key); + 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 SaveSecureString(string key, string value); - Task> GetBool(string key); - Task SaveBool(string key, bool value); - Task> GetInt(string key); - Task SaveInt(string key, int value); - Task ClearAll(); + Task> GetSecureString(string key); + Result RemoveSecure(string key); } diff --git a/src/NexusReader.Application/Constants/PlanConstants.cs b/src/NexusReader.Application/Constants/PlanConstants.cs index fdee337..9b5d200 100644 --- a/src/NexusReader.Application/Constants/PlanConstants.cs +++ b/src/NexusReader.Application/Constants/PlanConstants.cs @@ -2,14 +2,7 @@ namespace NexusReader.Application.Constants; public static class PlanConstants { - public const string Free = "Free"; - public const string Pro = "Pro"; - public const string Enterprise = "Enterprise"; - - public static int GetTokenLimit(string planName) => planName switch - { - Pro => 100000, - Enterprise => 1000000, - _ => 10000 - }; + public const string DefaultPlanName = "Free"; + public const int DefaultTokenLimit = 1000; + public const string DefaultActivityLabel = "Brak aktywności"; } diff --git a/src/NexusReader.Application/Constants/StorageKeys.cs b/src/NexusReader.Application/Constants/StorageKeys.cs index fdde604..d9225b3 100644 --- a/src/NexusReader.Application/Constants/StorageKeys.cs +++ b/src/NexusReader.Application/Constants/StorageKeys.cs @@ -2,11 +2,9 @@ namespace NexusReader.Application.Constants; public static class StorageKeys { - public const string AuthToken = "authToken"; - public const string RefreshToken = "refreshToken"; - public const string UserEmail = "userEmail"; - public const string UserTenant = "userTenant"; - public const string UserRoles = "userRoles"; - public const string Theme = "theme"; - public const string FocusMode = "focusMode"; + public const string AuthToken = "nexus_auth_token"; + 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 9d40c11..7060c49 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -5,28 +5,27 @@ namespace NexusReader.Application.DTOs.User; public record UserProfileDto { public string Email { get; init; } = string.Empty; - public Guid TenantId { get; init; } - public SubscriptionPlanDto Plan { get; init; } = new(); public int AITokensUsed { get; init; } - public string[] Roles { get; init; } = Array.Empty(); + public Guid TenantId { get; init; } - // Statistics for Dashboard - public int TotalBooksRead { get; init; } + /// + /// Relational data for the current subscription plan. + /// + public SubscriptionPlanDto Plan { get; init; } = new(); + public int AverageQuizScore { get; init; } + + /// + /// Summary of the last read book. + /// public LastReadBookDto? LastReadBook { get; init; } - // UI Helpers - public string CurrentPlan => Plan.Name ?? PlanConstants.Free; - public int AITokenLimit => Plan.AITokenLimit > 0 ? Plan.AITokenLimit : PlanConstants.GetTokenLimit(CurrentPlan); - public string LastReadBookTitle => LastReadBook?.Title ?? "Brak aktywności"; -} + public string[] Roles { get; init; } = Array.Empty(); -public record SubscriptionPlanDto -{ - public int Id { get; init; } - public string Name { get; init; } = string.Empty; - public int AITokenLimit { get; init; } - public decimal MonthlyPrice { get; init; } + // 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 @@ -35,13 +34,7 @@ public record LastReadBookDto public string Title { get; init; } = string.Empty; public AuthorDto Author { get; init; } = new(); public string? CoverUrl { get; init; } - public int Progress { get; init; } - public string LastChapter { get; init; } = string.Empty; + public double Progress { get; init; } + public string? LastChapter { get; init; } public int LastChapterIndex { get; init; } } - -public record AuthorDto -{ - public Guid Id { get; init; } - public string Name { get; init; } = string.Empty; -} diff --git a/src/NexusReader.Application/Mappings/MappingConfig.cs b/src/NexusReader.Application/Mappings/MappingConfig.cs index 62ef934..861a1c7 100644 --- a/src/NexusReader.Application/Mappings/MappingConfig.cs +++ b/src/NexusReader.Application/Mappings/MappingConfig.cs @@ -1,13 +1,14 @@ using Mapster; -using NexusReader.Application.DTOs.User; -using NexusReader.Domain.Entities; +using MapsterMapper; using Microsoft.Extensions.DependencyInjection; +using NexusReader.Domain.Entities; +using NexusReader.Application.DTOs.User; namespace NexusReader.Application.Mappings; public static class MappingConfig { - public static void RegisterMappings(this IServiceCollection services) + public static IServiceCollection AddMapsterConfiguration(this IServiceCollection services) { var config = TypeAdapterConfig.GlobalSettings; @@ -16,5 +17,7 @@ public static class MappingConfig services.AddSingleton(config); services.AddScoped(); + + return services; } } diff --git a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs index 8938e4a..7f70859 100644 --- a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs @@ -4,17 +4,17 @@ using NexusReader.Application.Abstractions.Services; namespace NexusReader.Application.Queries.Reader; -public class GetReaderPageQueryHandler : IQueryHandler +internal sealed class GetReaderPageQueryHandler : IQueryHandler { - private readonly IEpubReader _epubReader; + private readonly IEpubReader _epubService; - public GetReaderPageQueryHandler(IEpubReader epubReader) + public GetReaderPageQueryHandler(IEpubReader epubService) { - _epubReader = epubReader; + _epubService = epubService; } public async Task> Handle(GetReaderPageQuery request, CancellationToken cancellationToken) { - return await _epubReader.GetEpubContentAsync(request.ChapterIndex, request.UserId); + return await _epubService.GetEpubContentAsync(request.ChapterIndex, request.UserId); } } 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/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 2709c48..46d4fc4 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -1,16 +1,21 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using NexusReader.Application.Abstractions.Services; +using Microsoft.Extensions.Configuration; +using Pgvector.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.AI; +using GeminiDotnet; +using GeminiDotnet.Extensions.AI; using NexusReader.Data.Persistence; + +using NexusReader.Application.Abstractions.Services; using NexusReader.Infrastructure.Services; -using Microsoft.AspNetCore.Identity; +using NexusReader.Infrastructure.Configuration; +using Polly; +using Polly.Retry; using NexusReader.Domain.Entities; -using NexusReader.Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Authorization; -using NexusReader.Application.Commands.Sync; -using NexusReader.Infrastructure.Handlers; -using MediatR; +using NexusReader.Application.Security.Authorization; namespace NexusReader.Infrastructure; @@ -18,54 +23,73 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { - var connectionString = configuration.GetConnectionString("DefaultConnection") - ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); - - // Register DB Context Factory for multi-threaded/asynchronous safety in Blazor - services.AddDbContextFactory(options => + var pgConnectionString = configuration.GetConnectionString("PostgresConnection"); + if (!string.IsNullOrEmpty(pgConnectionString)) { - if (connectionString.Contains("Host=")) + services.AddDbContextFactory(options => + options.UseNpgsql(pgConnectionString, x => x.UseVector())); + } + else + { + var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db"; + services.AddDbContextFactory(options => + options.UseSqlite(sqliteConnectionString)); + } + + services.Configure(configuration.GetSection(AiSettings.SectionName)); + services.Configure(configuration.GetSection(StripeSettings.SectionName)); + var aiSettings = configuration.GetSection(AiSettings.SectionName).Get() ?? new AiSettings(); + + Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}"); + + if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER") + { + Console.WriteLine("[Infrastructure] WARNING: AI API Key is missing or placeholder!"); + } + + services.AddResiliencePipeline("ai-retry", builder => + { + builder.AddRetry(new RetryStrategyOptions { - options.UseNpgsql(connectionString, o => o.UseVector()); - } - else - { - options.UseSqlite(connectionString); - } + ShouldHandle = new PredicateBuilder().Handle(ex => + ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + MaxRetryAttempts = aiSettings.RetryAttempts, + Delay = TimeSpan.FromSeconds(2) + }); }); - // Register Scoped Context for traditional usage - services.AddDbContext(options => + services.AddChatClient(new GeminiChatClient(new GeminiClientOptions + { + ApiKey = aiSettings.ApiKey, + ModelId = aiSettings.Model + })); + + services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions { - if (connectionString.Contains("Host=")) - { - options.UseNpgsql(connectionString, o => o.UseVector()); - } - else - { - options.UseSqlite(connectionString); - } - }); + ApiKey = aiSettings.ApiKey, + ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004" + })); - // Identity Configuration - services.AddAuthorization(options => - { - options.AddPolicy("TokenLimitPolicy", policy => - policy.Requirements.Add(new TokenLimitRequirement())); - }); - - services.AddScoped(); - - // Services - services.AddScoped(); - services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); - // Handlers (MediatR) - services.AddScoped, UpdateReadingProgressCommandHandler>(); + services.AddAuthorizationCore(options => + { + options.AddPolicy("ProUser", policy => policy.Requirements.Add(new ProUserRequirement())); + }); + + services.AddScoped(); + + services.AddScoped(); return services; } + + public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly; } + +public interface IInfrastructureMarker { } +internal class InfrastructureMarker : IInfrastructureMarker { } diff --git a/src/NexusReader.Infrastructure/Services/EpubService.cs b/src/NexusReader.Infrastructure/Services/EpubService.cs index fbc5de1..a473e9e 100644 --- a/src/NexusReader.Infrastructure/Services/EpubService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubService.cs @@ -34,7 +34,7 @@ public class EpubReaderService : IEpubReader 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,6 +215,7 @@ public class EpubReaderService : IEpubReader } return null; } + // Metadata extraction moved to EpubMetadataExtractor } public class EpubMetadataExtractor : IEpubMetadataExtractor diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index b43162e..41c3718 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -1,139 +1,149 @@ @using Microsoft.AspNetCore.Components.Forms @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Queries.Reader +@inject IEpubMetadataExtractor MetadataExtractor +@inject ILogger Logger @implements IAsyncDisposable -@inject IEpubMetadataExtractor EpubMetadataExtractor - +} @code { /// - /// Gets or sets a value indicating whether the modal is visible. + /// Gets or sets a value indicating whether the modal is open. /// - [Parameter] public bool IsVisible { get; set; } - - /// - /// Event callback triggered when the modal is closed. - /// - [Parameter] public EventCallback OnClose { get; set; } - - /// - /// Event callback triggered when a book is successfully ingested. - /// Passes the extracted metadata to the parent component. - /// - [Parameter] public EventCallback OnBookAdded { get; set; } + [Parameter] + public bool IsOpen { get; set; } - private bool IsDragging { 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() { - Metadata = null; - await OnClose.InvokeAsync(); + IsOpen = false; + Reset(); + await IsOpenChanged.InvokeAsync(false); } - private void HandleDragEnter() => IsDragging = true; - private void HandleDragLeave() => IsDragging = false; - - private async Task HandleDrop(DragEventArgs e) + private void Reset() { - IsDragging = false; - // JS interop would be needed for actual drag-drop file access in Blazor WASM - // This is a placeholder for the interaction pattern + 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 { - // Requirement: Extract metadata locally before uploading - // We limit the size for extraction safety - using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB + using var stream = file.OpenReadStream(MaxFileSize); - // Using MemoryStream to ensure the provider doesn't close the stream prematurely + // 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 EpubMetadataExtractor.ExtractMetadataAsync(memoryStream); + 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) { - // Log or handle error - Console.WriteLine($"Metadata extraction failed: {ex.Message}"); + Logger.LogError(ex, "Error uploading EPUB"); + ErrorMessage = $"An unexpected error occurred: {ex.Message} \n {ex.StackTrace}"; } finally { @@ -141,22 +151,6 @@ StateHasChanged(); } } - - private async Task ConfirmIngestion() - { - if (Metadata != null) - { - await OnBookAdded.InvokeAsync(Metadata); - await CloseModal(); - } - } - - private void Reset() - { - Metadata = null; - IsParsing = false; - } - public ValueTask DisposeAsync() { // Cleanup if necessary diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css index f161ac1..635ca08 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css @@ -1,167 +1,272 @@ -.ingestion-modal { +.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; - align-items: center; justify-content: center; + align-items: center; z-index: 1000; - opacity: 0; + 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; - transition: opacity 0.3s ease; } -.ingestion-modal.visible { - opacity: 1; - pointer-events: auto; +.drop-zone-content svg { + color: var(--nexus-accent, #00ffaa); + opacity: 0.8; } -.modal-overlay { +.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%; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(8px); -} - -.modal-container { - position: relative; - width: 100%; - max-width: 500px; - background: var(--surface-card); - border: 1px solid var(--border-subtle); - border-radius: 24px; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); - padding: 32px; - transform: translateY(20px); - transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); -} - -.ingestion-modal.visible .modal-container { - transform: translateY(0); -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 24px; -} - -.upload-zone { - border: 2px dashed var(--border-subtle); - border-radius: 20px; - padding: 48px 24px; - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - text-align: center; - transition: all 0.2s ease; - background: rgba(255, 255, 255, 0.02); -} - -.upload-zone.dragging { - border-color: var(--accent-primary); - background: rgba(var(--accent-primary-rgb), 0.05); -} - -.file-input-label { - margin-top: 12px; - padding: 10px 24px; - background: var(--accent-primary); - color: white; - border-radius: 12px; + opacity: 0; cursor: pointer; - font-weight: 500; - transition: transform 0.2s ease; -} - -.file-input-label:hover { - transform: scale(1.05); -} - -.file-input-label input { - display: none; + 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: 16px; - padding: 40px 0; + gap: 1rem; } .spinner { width: 40px; height: 40px; - border: 3px solid rgba(var(--accent-primary-rgb), 0.1); - border-top-color: var(--accent-primary); + 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); } } -.metadata-preview { - display: flex; - gap: 24px; - padding: 16px; - background: rgba(255, 255, 255, 0.03); - border-radius: 16px; - border: 1px solid var(--border-subtle); -} - -.cover-wrapper { - width: 120px; - height: 180px; - border-radius: 8px; - overflow: hidden; - background: var(--surface-elevated); - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); -} - -.cover-wrapper img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.metadata-info { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - gap: 8px; -} - -.actions { - margin-top: 24px; - display: flex; - flex-direction: column; - gap: 12px; -} - -/* Accessibility: Support reduced motion */ @media (prefers-reduced-motion: reduce) { - .ingestion-modal, - .modal-container, - .file-input-label, - .upload-zone { - transition: none; - } - + .modal-backdrop, + .shimmer::before, .spinner { - animation-duration: 0s; + animation: none !important; + transition: none !important; } } diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor index 3feec48..8b6da95 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Login.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -125,7 +125,7 @@ var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe); if (result.IsSuccess) { - NavigationManager.NavigateTo("/"); + NavigationManager.NavigateTo("/", forceLoad: true); } else { diff --git a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor index 370aa41..f587f48 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() { 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/Services/NexusAuthenticationStateProvider.cs b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs index 82c03ea..7835422 100644 --- a/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs +++ b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs @@ -1,108 +1,118 @@ using System.Security.Claims; +using System.Text.Json; using Microsoft.AspNetCore.Components.Authorization; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Constants; namespace NexusReader.UI.Shared.Services; -/** - * - * Custom AuthenticationStateProvider that manages user sessions using local storage. - * - * - * SECURITY NOTE: Currently roles are stored in local storage as a comma-separated string - * for UI reactivity. In a production environment, roles should be extracted from a - * cryptographically signed JWT or validated via a back-channel to prevent client-side - * role escalation. Consider using ProtectedBrowserStorage for sensitive claims. - * - */ public class NexusAuthenticationStateProvider : AuthenticationStateProvider { private readonly INativeStorageService _storageService; - private AuthenticationState? _cachedState; + + // SECURITY NOTE: We currently store roles in local storage to persist state across refreshes. + // In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server) + // or encrypted storage/JWT claims validation to prevent client-side role tampering. + private const string TokenKey = StorageKeys.AuthToken; public NexusAuthenticationStateProvider(INativeStorageService storageService) { _storageService = storageService; } + public void ClearCache() + { + _cachedState = null; + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + private AuthenticationState? _cachedState; + public override async Task GetAuthenticationStateAsync() { - if (_cachedState != null) return _cachedState; - try { - var tokenResult = await _storageService.GetSecureString(StorageKeys.AuthToken); + if (_cachedState != null) return _cachedState; + + var tokenResult = await _storageService.GetSecureString(TokenKey); var token = tokenResult.IsSuccess ? tokenResult.Value : null; - if (string.IsNullOrWhiteSpace(token)) + // 1. Try Token-based auth + if (!string.IsNullOrWhiteSpace(token)) { - return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); - } + var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); + var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant); + var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles); - var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); - var tenantResult = await _storageService.GetSecureString(StorageKeys.UserTenant); - var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles); - - var email = emailResult.IsSuccess ? emailResult.Value : "unknown"; - var tenantId = tenantResult.IsSuccess ? tenantResult.Value : "default"; - var roles = rolesResult.IsSuccess ? rolesResult.Value : ""; - - var identity = new ClaimsIdentity(new[] - { - new Claim(ClaimTypes.Name, email), - new Claim("TenantId", tenantId) - }, "api"); - - if (!string.IsNullOrEmpty(roles)) - { - foreach (var role in roles.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value)) { - identity.AddClaim(new Claim(ClaimTypes.Role, role.Trim())); + _cachedState = CreateState( + emailResult.Value, + tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", + "OpaqueBearer", + rolesResult.IsSuccess ? rolesResult.Value! : ""); + return _cachedState; } } - _cachedState = new AuthenticationState(new ClaimsPrincipal(identity)); - return _cachedState; + // 2. Try Cookie-based auth indicators + var storedEmailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); + if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value)) + { + var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant); + var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles); + _cachedState = CreateState( + storedEmailResult.Value, + tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", + "CookieAuth", + rolesResult.IsSuccess ? rolesResult.Value! : ""); + return _cachedState; + } + + // 3. Fallback: If we have no local info, we might still have a cookie (e.g. after refresh or Google login). + // We should return anonymous for now but trigger a background check if we're in WASM. + // Wait! In WASM, the first GetAuthenticationStateAsync is awaited. + // We can do a quick check here if it's the first time. + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } - catch + catch (Exception) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } } - public void NotifyUserAuthentication(string email, string tenantId, string roles = "") + private AuthenticationState CreateState(string email, string tenantId, string authType, string rolesStr = "") { - var identity = new ClaimsIdentity(new[] + var claims = new List { new Claim(ClaimTypes.Name, email), + new Claim(ClaimTypes.Email, email), new Claim("TenantId", tenantId) - }, "api"); - - if (!string.IsNullOrEmpty(roles)) + }; + + if (!string.IsNullOrEmpty(rolesStr)) { - foreach (var role in roles.Split(',', StringSplitOptions.RemoveEmptyEntries)) + var roles = rolesStr.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var role in roles) { - identity.AddClaim(new Claim(ClaimTypes.Role, role.Trim())); + claims.Add(new Claim(ClaimTypes.Role, role.Trim())); } } + + var identity = new ClaimsIdentity(claims, authType); + return new AuthenticationState(new ClaimsPrincipal(identity)); + } - var user = new ClaimsPrincipal(identity); - _cachedState = new AuthenticationState(user); - var authState = Task.FromResult(_cachedState); - NotifyAuthenticationStateChanged(authState); + public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "") + { + _cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr); + NotifyAuthenticationStateChanged(Task.FromResult(_cachedState)); } public void NotifyUserLogout() - { - var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity()); - _cachedState = new AuthenticationState(anonymousUser); - var authState = Task.FromResult(_cachedState); - NotifyAuthenticationStateChanged(authState); - } - - public void ClearCache() { _cachedState = null; + var guest = new ClaimsPrincipal(new ClaimsIdentity()); + NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest))); } } 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/Program.cs b/src/NexusReader.Web.Client/Program.cs index 502d247..461854d 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -1,52 +1,66 @@ -using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using NexusReader.Web.Client; -using NexusReader.UI.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; using NexusReader.Application.Abstractions.Services; using NexusReader.Web.Client.Services; -using Microsoft.AspNetCore.Components.Authorization; -using NexusReader.Web.Client.Handlers; +using NexusReader.UI.Shared.Services; using NexusReader.Application; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.AI; +using NexusReader.Data.Persistence; + var builder = WebAssemblyHostBuilder.CreateDefault(args); -// --- Identity & Auth --- -builder.Services.AddAuthorizationCore(); -builder.Services.AddScoped(); - -// --- Storage & Platform --- -builder.Services.AddScoped(); +// Platform & UI Services builder.Services.AddScoped(); - -// --- Identity Service (WASM) --- -builder.Services.AddScoped(); - -// --- App Services --- -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); -// --- Application Layer (Mappings etc) --- -builder.Services.AddApplication(); +// Identity & Auth Services +builder.Services.AddOptions(); +builder.Services.AddAuthorizationCore(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddCascadingAuthenticationState(); -// --- HttpClient Configuration --- -builder.Services.AddTransient(); +// AI & Content Services +builder.Services.AddScoped(); +builder.Services.AddTransient(); builder.Services.AddHttpClient("NexusAPI", client => - { - client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress); - }) - .AddHttpMessageHandler(); +{ + client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress); +}).AddHttpMessageHandler(); -// Default HttpClient for services that don't need the header handler (or handle it themselves) builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); +// Dummy registrations for server-only handlers to satisfy DI validation +builder.Services.AddSingleton>(new ThrowingDbContextFactory()); +builder.Services.AddSingleton>>(new ThrowingEmbeddingGenerator()); + +builder.Services.AddApplication(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + await builder.Build().RunAsync(); + +public class ThrowingDbContextFactory : IDbContextFactory +{ + public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client."); +} + +public class ThrowingEmbeddingGenerator : IEmbeddingGenerator> +{ + public void Dispose() { } + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Embedding generation cannot be used in WASM client."); + public object? GetService(Type serviceType, object? serviceKey = null) => null; +} diff --git a/src/NexusReader.Web.Client/Services/WasmEpubService.cs b/src/NexusReader.Web.Client/Services/WasmEpubService.cs index a6a5421..63c5760 100644 --- a/src/NexusReader.Web.Client/Services/WasmEpubService.cs +++ b/src/NexusReader.Web.Client/Services/WasmEpubService.cs @@ -2,7 +2,6 @@ using System.Net.Http.Json; using FluentResults; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Queries.Reader; -using VersOne.Epub; namespace NexusReader.Web.Client.Services; @@ -19,19 +18,24 @@ public class WasmEpubReader : IEpubReader { try { - var response = await _httpClient.GetAsync($"api/epub/content?index={chapterIndex}&userId={userId}"); + var response = await _httpClient.GetAsync($"/api/epub/{chapterIndex}"); if (response.IsSuccessStatusCode) { - var result = await response.Content.ReadFromJsonAsync(); - return result != null ? Result.Ok(result) : Result.Fail("Failed to deserialize reader page."); + var viewModel = await response.Content.ReadFromJsonAsync(); + return viewModel != null ? Result.Ok(viewModel) : Result.Fail("Failed to deserialize response."); } - return Result.Fail("Failed to fetch EPUB content from server."); + + // Try to read the error message from the body + var errorBody = await response.Content.ReadAsStringAsync(); + return Result.Fail($"Server error ({response.StatusCode}): {errorBody}"); } catch (Exception ex) { - return Result.Fail(new Error("Network error while fetching EPUB content.").CausedBy(ex)); + // Fallback for network errors or parsing exceptions + return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex)); } } + // Metadata extraction moved to WasmEpubMetadataExtractor } public class WasmEpubMetadataExtractor : IEpubMetadataExtractor @@ -40,7 +44,7 @@ public class WasmEpubMetadataExtractor : IEpubMetadataExtractor { try { - using var bookRef = await EpubReader.OpenBookAsync(epubStream); + 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(); diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs deleted file mode 100644 index 1cfd11c..0000000 --- a/src/NexusReader.Web.New/Program.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using NexusReader.Application.Abstractions.Services; -using NexusReader.Application.Queries.Reader; -using NexusReader.Data.Persistence; -using NexusReader.Domain.Entities; -using NexusReader.Infrastructure.Configuration; -using NexusReader.Infrastructure.RealTime; -using NexusReader.UI.Shared.Services; -using NexusReader.Web.Components; -using NexusReader.Web.New.Services; -using NexusReader.Infrastructure; -using NexusReader.Application; - -var builder = WebApplication.CreateBuilder(args); - -// --- Configuration --- -builder.Services.Configure(builder.Configuration.GetSection("AiSettings")); -builder.Services.Configure(builder.Configuration.GetSection("StripeSettings")); - -// --- Infrastructure Layer --- -builder.Services.AddInfrastructure(builder.Configuration); - -// --- Application Layer --- -builder.Services.AddApplication(); - -// --- Identity & Auth --- -builder.Services.AddCascadingAuthenticationState(); -builder.Services.AddScoped(); - -// Register Server-Side Native Storage (JSInterop Wrapper) -builder.Services.AddScoped(); - -// Register Server-Side Identity Service -builder.Services.AddScoped(); - -builder.Services.AddAuthentication(options => - { - options.DefaultScheme = IdentityConstants.ApplicationScheme; - options.DefaultSignInScheme = IdentityConstants.ExternalScheme; - }) - .AddIdentityCookies(); - -builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) - .AddRoles() - .AddEntityFrameworkStores() - .AddSignInManager() - .AddDefaultTokenProviders(); - -// --- Razor Components --- -builder.Services.AddRazorComponents() - .AddInteractiveWebAssemblyComponents() - .AddInteractiveServerComponents(); - -// --- API Client for WASM Compatibility --- -builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "https://localhost:7165/") }); - -var app = builder.Build(); - -// --- Database Initialization --- -using (var scope = app.Services.CreateScope()) -{ - var services = scope.ServiceProvider; - try - { - var context = services.GetRequiredService(); - if (context.Database.IsRelational()) - { - await context.Database.MigrateAsync(); - } - await DbInitializer.InitializeAsync(services); - } - catch (Exception ex) - { - var logger = services.GetRequiredService>(); - logger.LogError(ex, "An error occurred while migrating or seeding the database."); - } -} - -// --- Middleware Pipeline --- -if (app.Environment.IsDevelopment()) -{ - app.UseWebAssemblyDebugging(); -} -else -{ - app.UseExceptionHandler("/Error", createScopeForErrors: true); - app.UseHsts(); -} - -app.UseHttpsRedirection(); -app.UseAntiforgery(); -app.MapStaticAssets(); - -app.MapRazorComponents() - .AddInteractiveWebAssemblyRenderMode() - .AddInteractiveServerRenderMode() - .AddAdditionalAssemblies(typeof(NexusReader.UI.Shared._Imports).Assembly) - .AddAdditionalAssemblies(typeof(NexusReader.Web.Client._Imports).Assembly); - -// --- API Endpoints --- -app.MapHub("/hubs/sync"); - -app.MapGet("/api/epub/content", async (int index, string? userId, IEpubReader epubReader) => -{ - var result = await epubReader.GetEpubContentAsync(index, userId); - return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Errors); -}); - -app.Run(); diff --git a/src/NexusReader.Web.New/Services/NativeStorageService.cs b/src/NexusReader.Web.New/Services/NativeStorageService.cs deleted file mode 100644 index e76603c..0000000 --- a/src/NexusReader.Web.New/Services/NativeStorageService.cs +++ /dev/null @@ -1,82 +0,0 @@ -using FluentResults; -using Microsoft.JSInterop; -using NexusReader.Application.Abstractions.Services; - -namespace NexusReader.Web.New.Services; - -/// -/// Server-side implementation of INativeStorageService for Blazor Server. -/// Uses JS Interop to access browser local storage. -/// -public class NativeStorageService : INativeStorageService -{ - private readonly IJSRuntime _jsRuntime; - - public NativeStorageService(IJSRuntime jsRuntime) - { - _jsRuntime = jsRuntime; - } - - public async Task> GetSecureString(string key) - { - try - { - var value = await _jsRuntime.InvokeAsync("localStorage.getItem", key); - return Result.Ok(value ?? string.Empty); - } - catch (Exception ex) - { - return Result.Fail(new Error("Failed to read from local storage").CausedBy(ex)); - } - } - - public async Task SaveSecureString(string key, string value) - { - try - { - await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value); - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail(new Error("Failed to write to local storage").CausedBy(ex)); - } - } - - public async Task> GetBool(string key) - { - var result = await GetSecureString(key); - if (result.IsFailed) return Result.Fail(result.Errors); - return Result.Ok(bool.TryParse(result.Value, out var val) && val); - } - - public async Task SaveBool(string key, bool value) - { - return await SaveSecureString(key, value.ToString().ToLower()); - } - - public async Task> GetInt(string key) - { - var result = await GetSecureString(key); - if (result.IsFailed) return Result.Fail(result.Errors); - return Result.Ok(int.TryParse(result.Value, out var val) ? val : 0); - } - - public async Task SaveInt(string key, int value) - { - return await SaveSecureString(key, value.ToString()); - } - - public async Task ClearAll() - { - try - { - await _jsRuntime.InvokeVoidAsync("localStorage.clear"); - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail(new Error("Failed to clear local storage").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 2a9db44..0000000 --- a/src/NexusReader.Web.New/Services/ServerIdentityService.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Security.Claims; -using FluentResults; -using Microsoft.AspNetCore.Identity; -using NexusReader.Application.Abstractions.Services; -using NexusReader.Application.DTOs.User; -using NexusReader.Domain.Entities; -using Mapster; -using Microsoft.EntityFrameworkCore; -using NexusReader.Data.Persistence; - -namespace NexusReader.Web.New.Services; - -public class ServerIdentityService : IIdentityService -{ - private readonly UserManager _userManager; - private readonly SignInManager _signInManager; - private readonly INativeStorageService _storageService; - private readonly IDbContextFactory _contextFactory; - - public event Func? OnStateInvalidated; - - public ServerIdentityService( - UserManager userManager, - SignInManager signInManager, - INativeStorageService storageService, - IDbContextFactory contextFactory) - { - _userManager = userManager; - _signInManager = signInManager; - _storageService = storageService; - _contextFactory = contextFactory; - } - - public async Task RegisterAsync(string email, string password) - { - var user = new NexusUser { UserName = email, Email = email }; - var result = await _userManager.CreateAsync(user, password); - return result.Succeeded ? Result.Ok() : Result.Fail(result.Errors.Select(e => e.Description)); - } - - public async Task LoginAsync(string email, string password, bool rememberMe = false) - { - var result = await _signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: false); - if (result.Succeeded) - { - if (OnStateInvalidated != null) await OnStateInvalidated.Invoke(); - return Result.Ok(); - } - return Result.Fail("Login failed"); - } - - public async Task LogoutAsync() - { - await _signInManager.SignOutAsync(); - if (OnStateInvalidated != null) await OnStateInvalidated.Invoke(); - return Result.Ok(); - } - - public async Task> GetProfileAsync() - { - // This is a simplified server-side profile fetch - // In a real app, you'd get the current user from HttpContext - return Result.Fail("Profile fetch not implemented on server identity service yet"); - } - - public Task RefreshTokenAsync() - { - return Task.FromResult(Result.Ok()); - } -} 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 100% rename from src/NexusReader.Web.New/NexusReader.Web.csproj rename to src/NexusReader.Web/NexusReader.Web.csproj diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs new file mode 100644 index 0000000..f9d9478 --- /dev/null +++ b/src/NexusReader.Web/Program.cs @@ -0,0 +1,455 @@ +using NexusReader.Web.Components; +using NexusReader.Application; +using NexusReader.Infrastructure; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Application.Queries.User; +using MediatR; +using NexusReader.Web.Client.Services; +using NexusReader.UI.Shared.Services; +using NexusReader.Domain.Entities; +using NexusReader.Data.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authorization; +using NexusReader.Infrastructure.Identity; +using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; +using NexusReader.Infrastructure.Services; +using Stripe; + +AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddInteractiveWebAssemblyComponents(); + +// Enable detailed circuit errors for Server‑Side Blazor components +builder.Services.AddServerSideBlazor() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }); +builder.Services.AddSignalR(); +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddHttpClient("NexusAPI", client => +{ + client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000"); +}); +builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddCascadingAuthenticationState(); + +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( + NexusReader.Application.DependencyInjection.Assembly, + NexusReader.Infrastructure.DependencyInjection.Assembly +)); + +// Authorization Policies +builder.Services.AddScoped(); +builder.Services.AddAuthorizationBuilder() + .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName)) + .AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); + +// Billing & Stripe +builder.Services.AddScoped(); + +// Authentication +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddGoogle(options => + { + options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id"; + options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "placeholder-secret"; + }); + +builder.Services.AddIdentityApiEndpoints() + .AddRoles() + .AddEntityFrameworkStores(); + +builder.Services.ConfigureApplicationCookie(options => +{ + options.LoginPath = "/account/login"; + options.LogoutPath = "/account/logout"; + options.AccessDeniedPath = "/account/access-denied"; + options.Cookie.Name = "NexusReader.Auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; + + options.Events.OnRedirectToLogin = context => + { + var isApiRequest = context.Request.Path.StartsWithSegments("/api") || + context.Request.Path.StartsWithSegments("/identity") || + context.Request.Headers["Accept"].ToString().Contains("application/json"); + + if (isApiRequest) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + } + else + { + context.Response.Redirect(context.RedirectUri); + } + return Task.CompletedTask; + }; +}); + +builder.Services.Configure(options => +{ + // Password settings + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Password.RequireUppercase = true; + options.Password.RequiredLength = 8; + options.Password.RequiredUniqueChars = 1; + + // Lockout settings + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; + + // User settings + options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; + options.User.RequireUniqueEmail = true; +}); + +var app = builder.Build(); + +// Startup Validation +using (var scope = app.Services.CreateScope()) +{ + var marker = scope.ServiceProvider.GetService(); + if (marker == null) + { + throw new InvalidOperationException("CRITICAL: Infrastructure layer was not registered. Ensure AddInfrastructure() is called in Program.cs."); + } +} + +// Ensure Database is initialized and seeded +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + var logger = services.GetRequiredService>(); + var dbContextFactory = services.GetRequiredService>(); + using var dbContext = await dbContextFactory.CreateDbContextAsync(); + + int maxRetries = 5; + int delayMs = 2000; + + for (int i = 0; i < maxRetries; i++) + { + try + { + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Próba połączenia z bazą danych (próba {Attempt}/{MaxRetries})...", i + 1, maxRetries); + } + + await dbContext.Database.MigrateAsync(); + await DbInitializer.SeedAsync(services); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Baza danych zainicjowana pomyślnie."); + } + break; + } + catch (Npgsql.NpgsqlException ex) when (i < maxRetries - 1) + { + if (logger.IsEnabled(LogLevel.Warning)) + { + logger.LogWarning(ex, "Błąd połączenia z bazą danych. Ponowna próba za {Delay}ms...", delayMs); + } + + await Task.Delay(delayMs); + delayMs *= 2; // Exponential backoff + } + catch (Exception ex) + { + if (logger.IsEnabled(LogLevel.Critical)) + { + logger.LogCritical(ex, "Krytyczny błąd podczas inicjalizacji bazy danych."); + } + throw; + } + } +} + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} + +app.UseAntiforgery(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapStaticAssets(); +app.MapHub("/synchub"); + +// API endpoint for WASM client to fetch EPUB content +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); + + if (result.IsSuccess) return Results.Ok(result.Value); + + var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; + return Results.BadRequest(errorMsg); +}).RequireAuthorization(); + +var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens"); + +knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => +{ + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId); + if (result.IsSuccess) return Results.Ok(result.Value); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); +}); + +knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => +{ + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId); + if (result.IsSuccess) return Results.Ok(result.Value); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); +}); + +knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => +{ + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId); + if (result.IsSuccess) return Results.Ok(result.Value); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); +}); + +knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => +{ + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, tenantId); + if (result.IsSuccess) return Results.Ok(result.Value); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); +}); + +knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) => +{ + var result = await knowledgeService.ClearCacheAsync(); + if (result.IsSuccess) return Results.Ok(); + + var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; + return Results.BadRequest(errorMsg); +}); + +app.MapPost("/api/StripeWebhook", async ( + HttpContext context, + UserManager userManager, + IConfiguration configuration, + IDbContextFactory dbContextFactory) => +{ + using var dbContext = await dbContextFactory.CreateDbContextAsync(); + var json = await new StreamReader(context.Request.Body).ReadToEndAsync(); + var webhookSecret = configuration["Stripe:WebhookSecret"] ?? ""; + + try + { + var stripeEvent = EventUtility.ConstructEvent( + json, + context.Request.Headers["Stripe-Signature"], + webhookSecret + ); + + switch (stripeEvent.Type) + { + case EventTypes.CheckoutSessionCompleted: + var session = stripeEvent.Data.Object as Stripe.Checkout.Session; + await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager, dbContext); + break; + + case EventTypes.CustomerSubscriptionUpdated: + var subscription = stripeEvent.Data.Object as Stripe.Subscription; + await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager, dbContext); + break; + + case EventTypes.CustomerSubscriptionDeleted: + var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription; + await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager, dbContext); + break; + } + return Results.Ok(); + } + catch (StripeException e) + { + return Results.BadRequest(e.Message); + } +}); + +async Task HandleSubscriptionSuccess( + string? email, + Dictionary? metadata, + UserManager userManager, + AppDbContext dbContext) +{ + if (string.IsNullOrEmpty(email)) return; + + var user = await userManager.FindByEmailAsync(email); + if (user != null) + { + var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName; + var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName); + + if (plan != null) + { + user.SubscriptionPlanId = plan.Id; + user.AITokenLimit = plan.AITokenLimit; + } + + await userManager.UpdateAsync(user); + } +} + +async Task HandleSubscriptionCancellation( + string? email, + UserManager userManager, + AppDbContext dbContext) +{ + if (string.IsNullOrEmpty(email)) return; + + var user = await userManager.FindByEmailAsync(email); + if (user != null) + { + var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId); + user.SubscriptionPlanId = SubscriptionPlan.FreeId; + user.AITokenLimit = freePlan?.AITokenLimit ?? 5000; + await userManager.UpdateAsync(user); + } +} + +app.MapGroup("/identity").MapIdentityApi(); + +app.MapGet("/identity/login/google", (string? returnUrl) => +{ + var properties = new AuthenticationProperties + { + RedirectUri = "/identity/callback/google", + Items = { { "returnUrl", returnUrl ?? "/" } } + }; + return Results.Challenge(properties, new[] { "Google" }); +}); + +app.MapGet("/identity/callback/google", async ( + HttpContext context, + SignInManager signInManager, + UserManager userManager, + ILogger logger) => +{ + var info = await signInManager.GetExternalLoginInfoAsync(); + if (info == null) + { + logger.LogWarning("External login info from Google is null."); + return Results.Redirect("/account/login?error=ExternalLoginFailed"); + } + + var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false); + if (result.Succeeded) + { + logger.LogInformation("User logged in via Google: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email)); + return Results.Redirect("/"); + } + + if (result.IsLockedOut) + { + logger.LogWarning("User account locked out during Google login: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email)); + return Results.Redirect("/account/login?error=LockedOut"); + } + + // New user provisioning + var email = info.Principal.FindFirstValue(ClaimTypes.Email); + if (email != null) + { + var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true }; + var createResult = await userManager.CreateAsync(user); + + if (createResult.Succeeded) + { + await userManager.AddLoginAsync(user, info); + await signInManager.SignInAsync(user, isPersistent: false); + logger.LogInformation("New user provisioned via Google: {Email}", email); + return Results.Redirect("/"); + } + + // Log specific errors + foreach (var error in createResult.Errors) + { + logger.LogError("Google provisioning failed for {Email}: {Code} - {Description}", email, error.Code, error.Description); + } + + if (createResult.Errors.Any(e => e.Code == "DuplicateEmail" || e.Code == "DuplicateUserName")) + { + return Results.Redirect("/account/login?error=UserAlreadyExists"); + } + } + + logger.LogError("Google provisioning failed - unknown reason for email {Email}", email); + return Results.Redirect("/account/login?error=ProvisioningFailed"); +}); + +app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) => +{ + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Results.Unauthorized(); + + var result = await mediator.Send(new GetUserProfileQuery(userId)); + if (result.IsFailed) return Results.NotFound(result.Errors.FirstOrDefault()?.Message); + + return Results.Ok(result.Value); +}).RequireAuthorization(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly); + +app.Run(); + +public record KnowledgeRequest(string Text); +public record GroundednessRequest(string Answer, string Context); 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..46e5c3e --- /dev/null +++ b/src/NexusReader.Web/Services/NativeStorageService.cs @@ -0,0 +1,80 @@ +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 Microsoft.JSInterop.IJSRuntime _jsRuntime; + + public NativeStorageService(Microsoft.JSInterop.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) + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task> GetSecureString(string key) + { + try + { + var value = await _jsRuntime.InvokeAsync("localStorage.getItem", key); + return Result.Ok(value); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result RemoveSecure(string key) => Remove(key); +} diff --git a/src/NexusReader.Web/Services/ServerIdentityService.cs b/src/NexusReader.Web/Services/ServerIdentityService.cs new file mode 100644 index 0000000..12e8379 --- /dev/null +++ b/src/NexusReader.Web/Services/ServerIdentityService.cs @@ -0,0 +1,131 @@ +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."); + + 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."); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Błąd podczas logowania na serwerze: {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)); + } + } + + 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) + { + await _signInManager.SignInAsync(user, isPersistent: false); + 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 index c3c84c2..e464bb1 100644 --- a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj +++ b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj @@ -1,29 +1,19 @@ - net10.0 enable enable false - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + + - - diff --git a/tests/NexusReader.Application.Tests/Services/EpubMetadataExtractorTests.cs b/tests/NexusReader.Application.Tests/Services/EpubMetadataExtractorTests.cs index 6b25ba5..102a24c 100644 --- a/tests/NexusReader.Application.Tests/Services/EpubMetadataExtractorTests.cs +++ b/tests/NexusReader.Application.Tests/Services/EpubMetadataExtractorTests.cs @@ -1,3 +1,6 @@ +using System.IO; +using System.Threading.Tasks; +using FluentAssertions; using NexusReader.Infrastructure.Services; using Xunit; @@ -5,24 +8,18 @@ namespace NexusReader.Application.Tests.Services; public class EpubMetadataExtractorTests { - private readonly EpubMetadataExtractor _sut; - - public EpubMetadataExtractorTests() - { - _sut = new EpubMetadataExtractor(); - } - [Fact] public async Task ExtractMetadataAsync_WithInvalidStream_ReturnsFailure() { // Arrange - using var invalidStream = new MemoryStream(new byte[] { 0, 1, 2, 3 }); + var extractor = new EpubMetadataExtractor(); + using var stream = new MemoryStream(new byte[] { 0, 1, 2, 3 }); // Act - var result = await _sut.ExtractMetadataAsync(invalidStream); + var result = await extractor.ExtractMetadataAsync(stream); // Assert - Assert.True(result.IsFailed); - Assert.Contains("Failed to extract EPUB metadata", result.Errors[0].Message); + result.IsSuccess.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); } }