diff --git a/.agent/skills/nexus-code-review/SKILL.md b/.agent/skills/nexus-code-review/SKILL.md index ee10dae..2dbd164 100644 --- a/.agent/skills/nexus-code-review/SKILL.md +++ b/.agent/skills/nexus-code-review/SKILL.md @@ -30,4 +30,14 @@ When conducting or receiving a code review for NexusReader, ensure the implement - [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`. ## 6. Code Review Comments -- [ ] **Specific Linking**: Comments should be linked to specific code. Try to avoid general comments about the entire pull request. + +### 6.1 Posting Comments +- [ ] **Code-Linked Comments**: Every review comment **must** be anchored to a specific file and line range using the Gitea inline comment API (`path` + `new_line_num`/`old_line_num`). Free-floating general comments are only acceptable for summary notes that cannot be attributed to a single location. +- [ ] **Severity Prefix**: Prefix each comment with its severity so the author can prioritize: `🔴 Blocking`, `🟡 Design/Architecture`, or `🟢 Minor/Suggestion`. +- [ ] **Actionable Guidance**: Each comment must include a concrete, actionable suggestion — not just a description of the problem. Where applicable, provide a corrected code snippet. + +### 6.2 Resolving Comments (Author Responsibility) +- [ ] **Reply Before Resolving**: When a review comment has been addressed, the author **must** reply to the specific thread explaining *how* the issue was resolved (e.g., commit SHA, approach taken, or a reasoned rejection with justification). Do not close a thread without a reply. +- [ ] **Link to Fix**: If the resolution is a code change, include the commit SHA or a reference to the changed line in the reply (e.g., `Fixed in abc1234 — moved the guard before CTS allocation`). +- [ ] **Close Only After Reply**: Mark a thread as **Resolved** only after posting the reply. A thread with no reply must remain open, even if the underlying code has changed. +- [ ] **Rejection Must Be Justified**: If the author disagrees with a comment and chooses not to act on it, they must reply with a clear technical justification. The reviewer then decides whether to accept the reasoning and close the thread, or escalate it. diff --git a/.env.test.template b/.env.test.template new file mode 100644 index 0000000..ba1f10a --- /dev/null +++ b/.env.test.template @@ -0,0 +1,41 @@ +# =================================================================== +# NexusReader — Test Environment Variables +# =================================================================== +# Copy this file to `.env` and fill in the values before deployment: +# cp .env.test.template .env +# +# Then deploy with: +# docker compose -f docker-compose.test.yml up -d --build +# =================================================================== + +# === PostgreSQL === +POSTGRES_USER=nexus_user +POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD +POSTGRES_DB=nexus_test_db +POSTGRES_PORT=5433 + +# === Neo4j === +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD + +# === Qdrant (leave empty to disable API key auth) === +QDRANT_API_KEY= + +# === Web App === +WEB_PORT=5050 + +# === Google OAuth (placeholder for test) === +GOOGLE_CLIENT_ID=placeholder +GOOGLE_CLIENT_SECRET=placeholder + +# === Gemini AI (placeholder for test) === +GOOGLE_AI_API_KEY=placeholder + +# === Admin Seed Password === +NEXUS_ADMIN_PASSWORD=CHANGE_ME + +# === Non-standard ports for auxiliary services === +QDRANT_HTTP_PORT=6343 +QDRANT_GRPC_PORT=6344 +NEO4J_HTTP_PORT=7484 +NEO4J_BOLT_PORT=7697 diff --git a/.gitignore b/.gitignore index 57f1b86..7fc6051 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ Thumbs.db *.epub .fake +.env src/NexusReader.Web/nexus.db src/NexusReader.Web/wwwroot/covers/ src/NexusReader.Web/wwwroot/uploads/ diff --git a/Dockerfile b/Dockerfile index 4e8f58e..b07de47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,17 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src +# Copy props files and solution-level configurations for Central Package Management +COPY ["Directory.Build.props", "./"] +COPY ["Directory.Packages.props", "./"] + # Copy csproj files and restore dependencies 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.Data/NexusReader.Data.csproj", "src/NexusReader.Data/"] COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"] RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..570ae65 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,97 @@ +services: + db: + image: pgvector/pgvector:pg17 + container_name: nexus-db-test + environment: + POSTGRES_USER: ${POSTGRES_USER:-nexus_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_DB: ${POSTGRES_DB:-nexus_test_db} + ports: + - "${POSTGRES_PORT:-5433}:5432" + volumes: + - pgdata_test:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexus_user} -d ${POSTGRES_DB:-nexus_test_db}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - nexus-test + restart: unless-stopped + + web: + build: + context: . + dockerfile: Dockerfile + container_name: nexus-web-test + ports: + - "${WEB_PORT:-5050}:5000" + environment: + - ASPNETCORE_ENVIRONMENT=Test + - ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_test_db};Username=${POSTGRES_USER:-nexus_user};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + - ConnectionStrings__QdrantConnection=http://qdrant:6334 + - ConnectionStrings__Neo4jConnection=bolt://neo4j:7687 + - Neo4j__Username=${NEO4J_USERNAME:-neo4j} + - Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required} + - Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder} + - Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder} + - Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder} + - NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required} + depends_on: + db: + condition: service_healthy + qdrant: + condition: service_healthy + neo4j: + condition: service_healthy + networks: + - nexus-test + restart: unless-stopped + + qdrant: + image: qdrant/qdrant:latest + container_name: nexus-qdrant-test + environment: + - QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-} + ports: + - "${QDRANT_HTTP_PORT:-6343}:6333" + - "${QDRANT_GRPC_PORT:-6344}:6334" + volumes: + - qdrant_test_data:/qdrant/storage + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - nexus-test + restart: unless-stopped + + neo4j: + image: neo4j:5-community + container_name: nexus-neo4j-test + environment: + - NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required} + ports: + - "${NEO4J_HTTP_PORT:-7484}:7474" + - "${NEO4J_BOLT_PORT:-7697}:7687" + volumes: + - neo4j_test_data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s + networks: + - nexus-test + restart: unless-stopped + +volumes: + pgdata_test: + qdrant_test_data: + neo4j_test_data: + +networks: + nexus-test: + driver: bridge diff --git a/src/NexusReader.Application/Abstractions/Services/IEpubReader.cs b/src/NexusReader.Application/Abstractions/Services/IEpubReader.cs index 3944179..d313d92 100644 --- a/src/NexusReader.Application/Abstractions/Services/IEpubReader.cs +++ b/src/NexusReader.Application/Abstractions/Services/IEpubReader.cs @@ -20,4 +20,17 @@ public interface IEpubReader int chapterIndex, string? userId = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves a resource (like an image) from the EPUB as a byte array. + /// + /// The unique ID of the ebook to read. + /// The path of the resource within the EPUB archive. + /// The authenticated user's ID (used for tenant isolation). + /// Cancellation token. + Task> GetEpubResourceAsync( + Guid ebookId, + string resourcePath, + string? userId = null, + CancellationToken cancellationToken = default); } diff --git a/src/NexusReader.Data/Persistence/DbInitializer.cs b/src/NexusReader.Data/Persistence/DbInitializer.cs index 0e86317..fc84c56 100644 --- a/src/NexusReader.Data/Persistence/DbInitializer.cs +++ b/src/NexusReader.Data/Persistence/DbInitializer.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; using NexusReader.Domain.Entities; using System; using System.Linq; @@ -16,6 +17,7 @@ public static class DbInitializer using var scope = serviceProvider.CreateScope(); var passwordHasher = scope.ServiceProvider.GetRequiredService>(); var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); + var configuration = scope.ServiceProvider.GetService(); using var dbContext = await dbContextFactory.CreateDbContextAsync(); try @@ -68,7 +70,31 @@ public static class DbInitializer SecurityStamp = Guid.NewGuid().ToString() }; - adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!"); + var adminPassword = configuration?["Nexus:AdminPassword"] + ?? configuration?["NEXUS_ADMIN_PASSWORD"] + ?? Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD"); + + var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") + ?? "Development"; + var isDevelopment = string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase); + + if (string.IsNullOrEmpty(adminPassword)) + { + if (!isDevelopment) + { + throw new InvalidOperationException( + "CRITICAL SECURITY ERROR: Admin password is NOT configured! " + + "In non-Development environments (e.g. Test/Production), the admin password must be explicitly set " + + "via configuration ('Nexus:AdminPassword' or 'NEXUS_ADMIN_PASSWORD') or environment variables. " + + "Seeding aborted to prevent insecure credentials fallback."); + } + + Console.WriteLine("[Seeder] WARNING: Admin password is not set. Falling back to default weak password 'Admin123!' in Development environment."); + adminPassword = "Admin123!"; + } + + adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword); dbContext.Users.Add(adminUser); await dbContext.SaveChangesAsync(); diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 9c39cca..a72162c 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -56,9 +56,14 @@ public static class DependencyInjection var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334"; services.AddSingleton(sp => new QdrantClient(new Uri(qdrantUrl))); - // Neo4j Driver registration + // Neo4j Driver registration (supports optional authentication) var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687"; - services.AddSingleton(sp => GraphDatabase.Driver(neo4jUrl, AuthTokens.None)); + var neo4jUser = configuration["Neo4j:Username"]; + var neo4jPass = configuration["Neo4j:Password"]; + var neo4jAuth = !string.IsNullOrEmpty(neo4jUser) + ? AuthTokens.Basic(neo4jUser, neo4jPass ?? string.Empty) + : AuthTokens.None; + services.AddSingleton(sp => GraphDatabase.Driver(neo4jUrl, neo4jAuth)); // Hangfire registration if (!string.IsNullOrEmpty(pgConnectionString)) diff --git a/src/NexusReader.Infrastructure/Services/EpubReaderService.cs b/src/NexusReader.Infrastructure/Services/EpubReaderService.cs index 03eef91..6079d42 100644 --- a/src/NexusReader.Infrastructure/Services/EpubReaderService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubReaderService.cs @@ -18,6 +18,16 @@ public class EpubReaderService : IEpubReader private readonly ILogger _logger; private const int WordThreshold = 1000; + private static readonly Regex ImageTagRegex = new(@"[^>]*?\bsrc=[""'])(?[^""']*?)(?[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex BodyMatchRegex = new(@"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?|]*>|]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex WhitelistTagsRegex = new(@"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr|img)\b)[^>]+>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex StripAttributesRegex = new(@"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex ImgTagSanitizerRegex = new(@"]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public EpubReaderService( IDbContextFactory dbContextFactory, ILogger logger) @@ -80,6 +90,9 @@ public class EpubReaderService : IEpubReader var chapterContent = await chapterRef.ReadContentAsTextAsync(); + // Rewrite relative image src URLs to use the server-side API endpoint + chapterContent = RewriteImageUrls(chapterContent, ebookId, chapterRef.FilePath); + // 3. Build content blocks var blocks = new List(); int totalWordCount = 0; @@ -142,13 +155,150 @@ public class EpubReaderService : IEpubReader return null; } + /// + public async Task> GetEpubResourceAsync( + Guid ebookId, + string resourcePath, + string? userId = null, + CancellationToken cancellationToken = default) + { + try + { + using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var ebook = await context.Ebooks + .AsNoTracking() + .FirstOrDefaultAsync( + e => e.Id == ebookId && (userId == null || e.UserId == userId), + cancellationToken); + + if (ebook == null) + { + return Result.Fail($"Ebook '{ebookId}' not found."); + } + + var fullPath = ResolvePath(ebook.FilePath); + if (fullPath == null || !File.Exists(fullPath)) + { + return Result.Fail("EPUB file not found."); + } + + using var bookRef = await EpubReader.OpenBookAsync(fullPath); + + var decodedPath = System.Net.WebUtility.UrlDecode(resourcePath); + if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\")) + { + return Result.Fail("Invalid resource path."); + } + + decodedPath = decodedPath.Replace('\\', '/').TrimStart('/'); + + EpubLocalContentFileRef? targetFile = null; + if (bookRef.Content?.AllFiles?.Local != null) + { + foreach (var file in bookRef.Content.AllFiles.Local) + { + var filePath = file.FilePath?.Replace('\\', '/').TrimStart('/') ?? ""; + var fileKey = file.Key?.Replace('\\', '/').TrimStart('/') ?? ""; + if (filePath.Equals(decodedPath, StringComparison.OrdinalIgnoreCase) || + fileKey.Equals(decodedPath, StringComparison.OrdinalIgnoreCase)) + { + targetFile = file; + break; + } + } + } + + if (targetFile != null) + { + if (targetFile is EpubLocalByteContentFileRef byteFile) + { + byte[] bytes = await byteFile.ReadContentAsync(); + return Result.Ok(bytes); + } + else if (targetFile is EpubLocalTextContentFileRef textFile) + { + string text = await textFile.ReadContentAsync(); + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(text); + return Result.Ok(bytes); + } + } + + return Result.Fail($"Resource '{resourcePath}' not found in EPUB."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve EPUB resource '{ResourcePath}' for ebook {EbookId}.", resourcePath, ebookId); + return Result.Fail(new Error($"Failed to retrieve EPUB resource: {ex.Message}").CausedBy(ex)); + } + } + + private static string RewriteImageUrls(string html, Guid ebookId, string chapterPath) + { + if (string.IsNullOrEmpty(html)) return html; + + return ImageTagRegex.Replace(html, match => + { + var rawSrc = match.Groups["src"].Value; + + if (rawSrc.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase)) + { + return ""; // Completely block script execution in image src + } + + if (rawSrc.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + rawSrc.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + rawSrc.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return match.Value; + } + + var resolvedPath = ResolveRelativePath(chapterPath, rawSrc); + var rewrittenSrc = $"/api/epub/{ebookId}/resource?path={System.Net.WebUtility.UrlEncode(resolvedPath)}"; + return $"{match.Groups["before"].Value}{rewrittenSrc}{match.Groups["after"].Value}"; + }); + } + + private static string ResolveRelativePath(string basePath, string relativePath) + { + if (string.IsNullOrEmpty(relativePath)) return string.Empty; + + var decodedRelative = System.Net.WebUtility.UrlDecode(relativePath); + var baseDir = Path.GetDirectoryName(basePath) ?? ""; + baseDir = baseDir.Replace('\\', '/'); + + var combined = Path.Combine(baseDir, decodedRelative).Replace('\\', '/'); + var segments = combined.Split('/'); + var stack = new Stack(); + + foreach (var segment in segments) + { + if (segment == "." || string.IsNullOrEmpty(segment)) + { + continue; + } + if (segment == "..") + { + if (stack.Count > 0) + { + stack.Pop(); + } + } + else + { + stack.Push(segment); + } + } + + return string.Join("/", stack.Reverse()); + } + private static List ExtractParagraphs(string html) { - var bodyMatch = Regex.Match(html, @"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); + var bodyMatch = BodyMatchRegex.Match(html); var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html; var paragraphs = new List(); - var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?|]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline); + var matches = ParagraphMatchRegex.Matches(content); foreach (Match match in matches) { @@ -165,9 +315,20 @@ public class EpubReaderService : IEpubReader private static string SanitizeParagraph(string html) { - var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); - clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase); - clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase); + var clean = StyleScriptRegex.Replace(html, ""); + clean = WhitelistTagsRegex.Replace(clean, ""); + clean = StripAttributesRegex.Replace(clean, "<$1>"); + + // Securely sanitize img tags by keeping ONLY src and alt attributes to prevent XSS (onerror, onload, style, etc.) + clean = ImgTagSanitizerRegex.Replace(clean, m => + { + var srcMatch = SrcAttributeRegex.Match(m.Value); + var altMatch = AltAttributeRegex.Match(m.Value); + var srcAttr = srcMatch.Success ? $" src=\"{srcMatch.Groups["src"].Value}\"" : ""; + var altAttr = altMatch.Success ? $" alt=\"{altMatch.Groups["alt"].Value}\"" : ""; + return $""; + }); + clean = System.Net.WebUtility.HtmlDecode(clean); return clean.Trim(); } diff --git a/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs b/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs index 871dbd6..19473ee 100644 --- a/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs +++ b/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs @@ -3,6 +3,7 @@ using System.Threading; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using NexusReader.Application.Abstractions.Services; +using NexusReader.UI.Shared.Services; namespace NexusReader.Maui.Infrastructure.Identity; @@ -55,7 +56,12 @@ public class MobileAuthenticationHeaderHandler : DelegatingHandler if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) { originalToken = tokenResult.Value; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken); + + // Only attach the Bearer token if it is not expired + if (!JwtTokenValidator.IsExpired(originalToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken); + } } } diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index 9f12805..a4b2c6c 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -63,12 +63,17 @@ public static class MauiProgram builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); // UI State + // Feature settings (avoiding direct raw IConfiguration injection in client pages) + var featureSettings = builder.Configuration.GetSection("Features").Get() ?? new FeatureSettings(); + builder.Services.AddSingleton(featureSettings); + 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(); diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor index 5bc025a..68dd356 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor @@ -10,6 +10,7 @@ break; + case "share": case "share-2": @@ -45,6 +46,7 @@ break; + case "book": case "book-open": @@ -86,6 +88,17 @@ case "log-out": break; + case "chevron-left": + + break; + case "chevron-right": + + break; + case "x": + case "close": + + + break; default: diff --git a/src/NexusReader.UI.Shared/Components/AuthenticationStatePersister.razor b/src/NexusReader.UI.Shared/Components/AuthenticationStatePersister.razor new file mode 100644 index 0000000..22f87fb --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/AuthenticationStatePersister.razor @@ -0,0 +1,41 @@ +@using Microsoft.AspNetCore.Components.Authorization +@inject PersistentComponentState ApplicationState +@inject AuthenticationStateProvider AuthenticationStateProvider +@implements IDisposable + +@code { + private PersistingComponentStateSubscription _subscription; + + protected override void OnInitialized() + { + _subscription = ApplicationState.RegisterOnPersisting(PersistAuthenticationStateAsync); + } + + private async Task PersistAuthenticationStateAsync() + { + var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var principal = authenticationState.User; + + if (principal.Identity?.IsAuthenticated == true) + { + var email = principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? principal.Identity.Name; + var tenantId = principal.FindFirst("TenantId")?.Value ?? "global"; + var roles = string.Join(",", principal.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value)); + + if (email != null) + { + ApplicationState.PersistAsJson("UserInfo", new UserInfo + { + Email = email, + TenantId = tenantId, + Roles = roles + }); + } + } + } + + public void Dispose() + { + _subscription.Dispose(); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor index 7b2f55b..9c10153 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor @@ -1,4 +1,5 @@ @using NexusReader.UI.Shared.Services +@using NexusReader.UI.Shared.Models @using NexusReader.Application.DTOs.AI @inject KnowledgeCoordinator Coordinator @inject IReaderInteractionService InteractionService diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index bb04130..d2abe30 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -27,14 +27,21 @@