Refactor: Web Consolidation and Identity Stabilization (#40)

## Overview
This PR completes the architectural consolidation of the web project and stabilizes the Identity-based authentication flow for the NexusReader application. It also refines the UI aesthetic for the Book Ingestion Modal as requested in #33.

## Key Changes
- **Project Consolidation**: Fully merged `NexusReader.Web.New` into `NexusReader.Web`. This includes updating all namespace references, VS Code launch/task configurations, and CI/CD (`Dockerfile`).
- **Identity Stabilization**:
  - Implemented `IIdentityService` on the server using `SignInManager<NexusUser>` and `UserManager<NexusUser>`.
  - Fixed registration logic to include mandatory fields (`SubscriptionPlanId`, `TenantId`).
  - Updated `Login.razor` to force a page reload on successful login, ensuring proper synchronization of authentication cookies between SignalR and the browser.
- **UI/UX Refinement**:
  - Updated `BookIngestionModal` styling to follow the **Nexus Neon** design system.
  - Added premium button styles with hover effects and glows.
  - Improved modal layout and interaction feedback (shimmer effects, spinner colors).
- **Cleanup**: Removed obsolete interfaces and constants that were superseded by newer Application layer implementations.

## Verification
- Successfully built the solution: `dotnet build NexusReader.slnx --no-restore`
- Verified project structure and file moves.
- Validated server-side authentication logic.

Fixes #33

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #40
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #40.
This commit is contained in:
2026-05-11 19:16:30 +00:00
committed by Marek Jaisński
parent f433e3c74a
commit fe5ff81c98
61 changed files with 1092 additions and 312 deletions
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
<link rel="stylesheet" href="NexusReader.Web.styles.css" />
<ImportMap />
<HeadOutlet @rendermode="InteractiveAuto" />
</head>
<body>
<div id="app-preloader">
<div class="preloader-spinner"></div>
<div class="preloader-text">Nexus Reader</div>
</div>
<NexusReader.UI.Shared.Routes @rendermode="InteractiveAuto" />
<ReconnectModal />
<script src="_framework/blazor.web.js"></script>
<script>
(function() {
function hidePreloader() {
const preloader = document.getElementById('app-preloader');
if (preloader) {
preloader.classList.add('loaded');
// Completely remove from DOM after transition for better accessibility
setTimeout(() => preloader.style.display = 'none', 1000);
}
}
if (document.readyState === 'complete') {
hidePreloader();
} else {
window.addEventListener('load', hidePreloader);
}
// Fallback: If for some reason 'load' doesn't fire (e.g. big assets), hide after 3s anyway
setTimeout(hidePreloader, 3000);
})();
</script>
</body>
</html>
@@ -0,0 +1,19 @@
@code {
[Parameter]
public Exception? Exception { get; set; }
protected override void OnInitialized()
{
// You could log the exception here using a logging service.
}
}
<h3 class="text-danger">An unexpected error occurred.</h3>
<p>Please try reloading the page. If the problem persists, contact support.</p>
@if (Exception != null)
{
<details class="mt-2">
<summary>Technical details (click to expand)</summary>
<pre style="white-space: pre-wrap; word-break: break-all;">@Exception.ToString()</pre>
</details>
}
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,14 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using NexusReader.Web
@using NexusReader.UI.Shared
@using NexusReader.UI.Shared.Layout
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Organisms
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<UserSecretsId>154e0538-f82d-4ec0-81e7-c1ff39d6d919</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Stripe.net" Version="51.1.0" />
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
</ItemGroup>
</Project>
+484
View File
@@ -0,0 +1,484 @@
using NexusReader.Web.Components;
using Microsoft.AspNetCore.Mvc;
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 ServerSide Blazor components
builder.Services.AddServerSideBlazor()
.AddCircuitOptions(options =>
{
options.DetailedErrors = true;
});
builder.Services.AddSignalR();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddHttpClient("NexusAPI", client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000");
});
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IIdentityService, NexusReader.Web.Services.ServerIdentityService>();
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<IAuthorizationHandler, TokenLimitHandler>();
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<IBillingService, NexusReader.Infrastructure.Services.BillingService>();
// 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<NexusUser>()
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>();
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<IdentityOptions>(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<IInfrastructureMarker>();
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<ILogger<Program>>();
var dbContextFactory = services.GetRequiredService<IDbContextFactory<NexusReader.Data.Persistence.AppDbContext>>();
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<NexusReader.Infrastructure.RealTime.SyncHub>("/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<NexusUser> userManager,
IConfiguration configuration,
IDbContextFactory<AppDbContext> 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<string, string>? metadata,
UserManager<NexusUser> 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<NexusUser> 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<NexusUser>();
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<NexusUser> signInManager,
UserManager<NexusUser> userManager,
ILogger<Program> 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.MapPost("/account/login-form", async (
[FromForm] string email,
[FromForm] string password,
[FromForm] bool rememberMe,
[FromForm] string? returnUrl,
SignInManager<NexusUser> signInManager,
ILogger<Program> logger) =>
{
var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
logger.LogInformation("User logged in: {Email}", email);
return Results.Redirect(returnUrl ?? "/");
}
var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials";
return Results.Redirect($"/account/login?error={error}");
}).DisableAntiforgery(); // Simplified for now, in production use proper antiforgery
app.MapGet("/account/logout-form", async (
SignInManager<NexusUser> signInManager,
ILogger<Program> logger) =>
{
await signInManager.SignOutAsync();
logger.LogInformation("User logged out via form.");
return Results.Redirect("/account/login");
});
app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
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<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly);
app.Run();
public record KnowledgeRequest(string Text);
public record GroundednessRequest(string Answer, string Context);
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5104",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7131;http://localhost:5104",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,81 @@
using FluentResults;
using Microsoft.JSInterop;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Services;
/// <summary>
/// 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).
/// </summary>
public class NativeStorageService : INativeStorageService
{
private readonly IJSRuntime _jsRuntime;
public NativeStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<Result> SaveStringAsync(string key, string value)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result<string?>> GetStringAsync(string key)
{
try
{
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
return Result.Ok(value);
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Task<Result> SaveBoolAsync(string key, bool value) => SaveStringAsync(key, value.ToString());
public async Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false)
{
try
{
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
if (string.IsNullOrEmpty(value)) return Result.Ok(defaultValue);
return Result.Ok(bool.TryParse(value, out var result) ? result : defaultValue);
}
catch
{
return Result.Ok(defaultValue);
}
}
public async Task<Result> RemoveAsync(string key)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result> SaveSecureString(string key, string value) => await SaveStringAsync(key, value);
public async Task<Result<string?>> GetSecureString(string key) => await GetStringAsync(key);
public Task<Result> RemoveSecureAsync(string key) => RemoveAsync(key);
}
@@ -0,0 +1,121 @@
using System.Security.Claims;
using FluentResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.DTOs.User;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Application.Queries.User;
using MediatR;
using NexusReader.Application.Constants;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Services;
public class ServerIdentityService : IIdentityService
{
private readonly UserManager<NexusUser> _userManager;
private readonly SignInManager<NexusUser> _signInManager;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IMediator _mediator;
private readonly INativeStorageService _storageService;
public event Func<Task>? OnStateInvalidated;
public ServerIdentityService(
UserManager<NexusUser> userManager,
SignInManager<NexusUser> signInManager,
IHttpContextAccessor httpContextAccessor,
IMediator mediator,
INativeStorageService storageService)
{
_userManager = userManager;
_signInManager = signInManager;
_httpContextAccessor = httpContextAccessor;
_mediator = mediator;
_storageService = storageService;
}
public async Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
{
try
{
var user = await _userManager.FindByEmailAsync(email);
if (user == null) return Result.Fail("Nieprawidłowy e-mail lub hasło.");
// Check if account is locked
if (await _userManager.IsLockedOutAsync(user)) return Result.Fail("Konto zostało zablokowane.");
// Check password
var isCorrect = await _userManager.CheckPasswordAsync(user, password);
if (!isCorrect)
{
await _userManager.AccessFailedAsync(user);
return Result.Fail("Nieprawidłowy e-mail lub hasło.");
}
// Reset access failed count on success
await _userManager.ResetAccessFailedCountAsync(user);
// In Blazor Interactive Server, we cannot use PasswordSignInAsync directly
// because headers are read-only once the circuit is established.
// We return success here to indicate credentials are valid.
// The UI will then perform a POST redirect to /account/login-form to set cookies.
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error($"Błąd podczas weryfikacji poświadczeń: {ex.Message}").CausedBy(ex));
}
}
public async Task<Result> LogoutAsync()
{
// Logout via SignalR is also problematic for cookie clearing.
// The UI should redirect to /account/logout-form
return Result.Ok();
}
public async Task<Result> RegisterAsync(string email, string password)
{
try
{
var user = new NexusUser
{
UserName = email,
Email = email,
SubscriptionPlanId = SubscriptionPlan.FreeId,
TenantId = "global"
};
var result = await _userManager.CreateAsync(user, password);
if (result.Succeeded)
{
// Similar to Login, we return success but don't sign in here.
return Result.Ok();
}
return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Rejestracja nie powiodła się.");
}
catch (Exception ex)
{
return Result.Fail(new Error($"Błąd podczas rejestracji na serwerze: {ex.Message}").CausedBy(ex));
}
}
public Task<Result> RefreshTokenAsync() => Task.FromResult(Result.Ok());
public async Task<Result<UserProfileDto>> 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);
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"Stripe": {
"ApiKey": "sk_test_placeholder",
"WebhookSecret": "whsec_placeholder",
"ProProductId": "prod_Pro123",
"BasicProductId": "prod_Basic456",
"FreeProductId": "prod_Free789"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"SqliteConnection": "Data Source=nexus.db",
"PostgresConnection": "Host=localhost;Database=nexus_db;Username=nexus_user;Password=nexus_password"
},
"Authentication": {
"Google": {
"ClientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"ClientSecret": "YOUR_CLIENT_SECRET"
}
},
"Ai": {
"Google": {
"ApiKey": "PLACEHOLDER",
"Model": "gemini-2.5-flash-lite",
"MaxInputTokens": 15000,
"MaxOutputTokens": 8192
}
},
"ApiBaseUrl": "http://localhost:5000"
}
+63
View File
@@ -0,0 +1,63 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&display=swap');
:root {
--nexus-neon: #00ff99;
--nexus-bg: #121212;
--nexus-card: #1e1e1e;
--nexus-text: #ffffff;
--nexus-font-sans: 'Inter', sans-serif;
--nexus-font-serif: 'Merriweather', serif;
}
.theme-light {
--nexus-bg: #F5F5F5;
--nexus-card: #FFFFFF;
--nexus-text: #1A1A1A;
}
body {
background-color: var(--nexus-bg);
color: var(--nexus-text);
font-family: var(--nexus-font-sans);
margin: 0;
padding: 0;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}