feat: implement identity authentication, authorization policies, and MAUI platform support with Docker orchestration

This commit is contained in:
2026-04-29 20:37:41 +02:00
parent 10efed0369
commit 0210611edf
55 changed files with 2359 additions and 949 deletions
+2 -1
View File
@@ -4,10 +4,11 @@
- **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor) - **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
- **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI". - **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
- **Architecture Role:** Lead Clean Architecture Specialist. - **Architecture Role:** Lead Clean Architecture Specialist.
- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas] - **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas, dotnet-async-void]
- **Technical Constraints:** - **Technical Constraints:**
- **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level. - **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level.
- **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components. - **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components.
- **Async:** Strict zero-tolerance for `async void`. All async operations must return `Task` or `ValueTask`. Event handlers must use `Func<Task>` or async-compatible patterns.
- **Error Handling:** All handlers must return `Result<T>` via `FluentResult`. - **Error Handling:** All handlers must return `Result<T>` via `FluentResult`.
- **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper. - **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper.
- **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance. - **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance.
+54
View File
@@ -0,0 +1,54 @@
# .NET Async Best Practices: Avoiding `async void`
This document defines the core rules for handling asynchronous operations in .NET, specifically focused on the dangers of `async void`.
## The Rule: NEVER Use `async void`
`async void` is a critical anti-pattern in .NET that must be avoided in almost all scenarios.
### Why `async void` is Dangerous:
1. **Untraceable Crashes**: Exceptions thrown in an `async void` method cannot be caught by a `try-catch` block outside the method. They often crash the entire process.
2. **Impossible to Await**: Callers cannot know when the operation has finished, leading to race conditions and "disposed object" exceptions (especially with `DbContext`).
3. **Broken Lifecycle**: In Blazor and ASP.NET Core, the DI container might dispose of scoped services (like `DbContext`) before the `async void` method completes its work.
## Correct Patterns
### 1. Standard Methods
Always return `Task` or `ValueTask` instead of `void`.
```csharp
// BAD
public async void SaveDataAsync() { ... }
// GOOD
public async Task SaveDataAsync() { ... }
```
### 2. Async Events (Blazor/Services)
When dealing with events that are defined as `Action` or `EventHandler`, do not use `async void` in the handler. Instead, refactor the event to support `Task`.
#### Pattern A: Use `Func<Task>` for Events
Instead of `Action`, use `Func<Task>` so the invoker can await all handlers.
```csharp
// Interface
event Func<Task>? OnChanged;
// Invoker
if (OnChanged != null)
{
foreach (var handler in OnChanged.GetInvocationList().Cast<Func<Task>>())
{
await handler();
}
}
```
#### Pattern B: Wrapper for Legacy Events
If you *must* subscribe to a legacy `void` event, use a Task-returning method and a lambda, but be aware of the "fire-and-forget" risks.
```csharp
// Better than async void, but still has lifecycle risks
NavigationService.OnChanged += () => { _ = HandleChangedAsync(); };
```
## Special Exceptions
The ONLY valid place for `async void` is in **Top-level Event Handlers** in legacy UI frameworks (WinForms/WPF) where the event signature is fixed and cannot be changed. Even then, the method body should be wrapped in a `try-catch` that logs or handles all exceptions.
In modern Blazor and Web development, there is **zero justification** for `async void`.
+12
View File
@@ -0,0 +1,12 @@
**/.git/
**/bin/
**/obj/
**/out/
**/dist/
**/.vscode/
**/.vs/
**/node_modules/
docker-compose*
Dockerfile*
.env
nexus.db*
+31
View File
@@ -0,0 +1,31 @@
# Stage 1: Build
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.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"
# Copy the rest of the source code
COPY . .
# Build and publish
WORKDIR "/src/src/NexusReader.Web.New"
RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# Environment variables
ENV ASPNETCORE_URLS=http://+:5000
EXPOSE 5000
ENTRYPOINT ["dotnet", "NexusReader.Web.dll"]
+12 -11
View File
@@ -10,10 +10,10 @@
| ID | Task Title | Description & Acceptance Criteria | Tech Stack | | ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---| |:---|:---|:---|:---|
| **BACK-1** | Define Extended `NexusUser` Model | **Description:** Create a `NexusUser` class inheriting from `IdentityUser`. Add custom properties for SaaS logic.<br>**AC:**<br>- [ ] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).<br>- [ ] Model placed in `NexusArchitect.Core` project. | C# / Identity | | **BACK-1** | Define Extended `NexusUser` Model | **Description:** Create a `NexusUser` class inheriting from `IdentityUser`. Add custom properties for SaaS logic.<br>**AC:**<br>- [x] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).<br>- [x] Model placed in `NexusArchitect.Core` project. | C# / Identity |
| **BACK-2** | Configure `ApplicationDbContext` for Identity | **Description:** Set up the DB context to inherit from `IdentityDbContext<NexusUser>`.<br>**AC:**<br>- [ ] Mapped standard Identity tables (Users, Roles, Claims).<br>- [ ] Configured 1-to-Many relationship between `NexusUser` and `Ebooks`. | EF Core | | **BACK-2** | Configure `ApplicationDbContext` for Identity | **Description:** Set up the DB context to inherit from `IdentityDbContext<NexusUser>`.<br>**AC:**<br>- [x] Mapped standard Identity tables (Users, Roles, Claims).<br>- [x] Configured 1-to-Many relationship between `NexusUser` and `Ebooks`. | EF Core |
| **BACK-3** | Database Schema Migration | **Description:** Generate and apply the initial migration for Identity tables.<br>**AC:**<br>- [ ] SQL schema contains all 7+ standard Identity tables.<br>- [ ] Custom `NexusUser` fields are correctly reflected in the `AspNetUsers` table. | EF Core CLI | | **BACK-3** | Database Schema Migration | **Description:** Generate and apply the initial migration for Identity tables.<br>**AC:**<br>- [x] SQL schema contains all 7+ standard Identity tables.<br>- [x] Custom `NexusUser` fields are correctly reflected in the `AspNetUsers` table. | EF Core CLI |
| **BACK-4** | Implement Identity API Endpoints | **Description:** Enable native .NET Identity API endpoints in `Program.cs`.<br>**AC:**<br>- [ ] Endpoints `/register`, `/login`, and `/refresh` are active.<br>- [ ] Verified functionality via Swagger/OpenAPI. | ASP.NET Core | | **BACK-4** | Implement Identity API Endpoints | **Description:** Enable native .NET Identity API endpoints in `Program.cs`.<br>**AC:**<br>- [x] Endpoints `/register`, `/login`, and `/refresh` are active.<br>- [x] Verified functionality via Swagger/OpenAPI. | ASP.NET Core |
--- ---
@@ -21,10 +21,10 @@
| ID | Task Title | Description & Acceptance Criteria | Tech Stack | | ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---| |:---|:---|:---|:---|
| **BACK-5** | Define Authorization Policies | **Description:** Implement Roles and Claims-based authorization (Free vs. Pro).<br>**AC:**<br>- [ ] Created a `ProUser` policy.<br>- [ ] Implemented a custom `Requirement` to check if `AITokensUsed < AITokenLimit`. | ASP.NET Core | | **BACK-5** | Define Authorization Policies | **Description:** Implement Roles and Claims-based authorization (Free vs. Pro).<br>**AC:**<br>- [x] Created a `ProUser` policy.<br>- [x] Implemented a custom `Requirement` to check if `AITokensUsed < AITokenLimit`. | ASP.NET Core |
| **UI-1** | Implement Login Page (Blazor) | **Description:** Build the Login UI based on the Dark Mode mockup.<br>**AC:**<br>- [ ] Theme: Dark mode with neon green accents.<br>- [ ] Components: Email/Password fields, "Remember Me" toggle, "Login" button.<br>- [ ] Integrates with `AuthenticationStateProvider`. | Blazor / CSS | | **UI-1** | Implement Login Page (Blazor) | **Description:** Build the Login UI based on the Dark Mode mockup.<br>**AC:**<br>- [x] Theme: Dark mode with neon green accents.<br>- [x] Components: Email/Password fields, "Remember Me" toggle, "Login" button.<br>- [x] Integrates with `AuthenticationStateProvider`. | Blazor / CSS |
| **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.<br>**AC:**<br>- [ ] Users can sign in via Google button.<br>- [ ] New users are automatically provisioned in the database upon successful OAuth. | OAuth / Google Cloud | | **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.<br>**AC:**<br>- [x] Users can sign in via Google button.<br>- [x] New users are automatically provisioned in the database upon successful OAuth. | OAuth / Google Cloud |
| **UI-3** | Implement Registration Flow | **Description:** Create a registration form calling the `/register` endpoint.<br>**AC:**<br>- [ ] Validation: Email format, password complexity (min 8 chars, uppercase, digit).<br>- [ ] Proper error handling for existing users. | Blazor | | **UI-3** | Implement Registration Flow | **Description:** Create a registration form calling the `/register` endpoint.<br>**AC:**<br>- [x] Validation: Email format, password complexity (min 8 chars, uppercase, digit).<br>- [x] Proper error handling for existing users. | Blazor |
--- ---
@@ -32,9 +32,10 @@
| ID | Task Title | Description & Acceptance Criteria | Tech Stack | | ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---| |:---|:---|:---|:---|
| **UI-4** | User Profile & Dashboard | **Description:** Build the User Profile UI focusing on "Active Learning" metrics.<br>**AC:**<br>- [ ] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.<br>- [ ] Links to subscription management. | Blazor | | **UI-4** | User Profile & Dashboard | **Description:** Build the User Profile UI focusing on "Active Learning" metrics.<br>**AC:**<br>- [x] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.<br>- [x] Links to subscription management. | Blazor |
| **MAUI-1** | Mobile Auth Integration (Blazor Hybrid) | **Description:** Ensure the authentication state is shared and persists in the MAUI container.<br>**AC:**<br>- [ ] Securely store JWT tokens in `SecureStorage`.<br>- [ ] Automatic login on app launch if token is valid. | MAUI / Blazor Hybrid | | **MAUI-1** | Mobile Auth Integration (Blazor Hybrid) | **Description:** Ensure the authentication state is shared and persists in the MAUI container.<br>**AC:**<br>- [x] Securely store JWT tokens in `SecureStorage`.<br>- [x] Automatic login on app launch if token is valid. | MAUI / Blazor Hybrid |
| **INTEG-1** | Stripe Subscription Webhooks | **Description:** Sync Identity Claims with Stripe subscription status.<br>**AC:**<br>- [ ] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.<br>- [ ] User is downgraded back to "Free" limit upon cancellation. | Stripe SDK / .NET | | **MAUI-2** | Secure Session Persistence | **Description:** Implement long-lived session management using encrypted device storage.<br>**AC:**<br>- [x] Refresh tokens implementation for mobile.<br>- [x] "Stay Signed In" functionality. | MAUI / Identity |
| **INTEG-1** | Stripe Subscription Webhooks | **Description:** Sync Identity Claims with Stripe subscription status.<br>**AC:**<br>- [x] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.<br>- [x] User is downgraded back to "Free" limit upon cancellation. | Stripe SDK / .NET |
--- ---
+37
View File
@@ -0,0 +1,37 @@
services:
db:
image: postgres:17-alpine
container_name: nexus-db
environment:
POSTGRES_USER: nexus_user
POSTGRES_PASSWORD: nexus_password
POSTGRES_DB: nexus_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nexus_user -d nexus_db"]
interval: 5s
timeout: 5s
retries: 5
web:
build:
context: .
dockerfile: Dockerfile
container_name: nexus-web
ports:
- "5000:5000"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__PostgresConnection=Host=db;Database=nexus_db;Username=nexus_user;Password=nexus_password
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
@@ -0,0 +1,9 @@
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Services;
public interface IBillingService
{
Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId);
Task<bool> HandleSubscriptionDeletedAsync(string customerEmail);
}
@@ -17,6 +17,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -31,4 +31,9 @@ public class NexusUser : IdentityUser
/// Collection of e-books owned by the user. /// Collection of e-books owned by the user.
/// </summary> /// </summary>
public ICollection<Ebook> Ebooks { get; set; } = new List<Ebook>(); public ICollection<Ebook> Ebooks { get; set; } = new List<Ebook>();
/// <summary>
/// Collection of quiz results completed by the user.
/// </summary>
public ICollection<QuizResult> QuizResults { get; set; } = new List<QuizResult>();
} }
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Tracks the result of an AI-generated quiz completed by a user.
/// </summary>
public class QuizResult
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public string UserId { get; set; } = string.Empty;
[ForeignKey(nameof(UserId))]
public NexusUser? User { get; set; }
[Required]
public string Topic { get; set; } = string.Empty;
public int Score { get; set; }
public int TotalQuestions { get; set; }
public double Percentage => TotalQuestions > 0 ? (double)Score / TotalQuestions * 100 : 0;
public DateTime CompletedDate { get; set; } = DateTime.UtcNow;
}
@@ -5,7 +5,7 @@ namespace NexusReader.Domain.Entities;
public class SemanticKnowledgeCache public class SemanticKnowledgeCache
{ {
[Key] [Key]
[MaxLength(64)] [MaxLength(128)]
public string ContentHash { get; set; } = string.Empty; public string ContentHash { get; set; } = string.Empty;
[Required] [Required]
@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -21,9 +21,18 @@ public static class DependencyInjection
{ {
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{ {
var connectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db"; var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
services.AddDbContext<AppDbContext>(options => if (!string.IsNullOrEmpty(pgConnectionString))
options.UseSqlite(connectionString)); {
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(pgConnectionString));
}
else
{
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(sqliteConnectionString));
}
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName)); services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings(); var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
@@ -5,37 +5,42 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence; using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260428142027_InitialIdentityAndEbooks")] [Migration("20260428184727_InitialPostgres")]
partial class InitialIdentityAndEbooks partial class InitialPostgres
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ConcurrencyStamp") b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("Name") b.Property<string>("Name")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.Property<string>("NormalizedName") b.Property<string>("NormalizedName")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.HasKey("Id"); b.HasKey("Id");
@@ -50,17 +55,19 @@ namespace NexusReader.Infrastructure.Migrations
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType") b.Property<string>("ClaimType")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ClaimValue") b.Property<string>("ClaimValue")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("RoleId") b.Property<string>("RoleId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
@@ -73,17 +80,19 @@ namespace NexusReader.Infrastructure.Migrations
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType") b.Property<string>("ClaimType")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ClaimValue") b.Property<string>("ClaimValue")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("UserId") b.Property<string>("UserId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
@@ -95,17 +104,17 @@ namespace NexusReader.Infrastructure.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{ {
b.Property<string>("LoginProvider") b.Property<string>("LoginProvider")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ProviderKey") b.Property<string>("ProviderKey")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ProviderDisplayName") b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("UserId") b.Property<string>("UserId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey"); b.HasKey("LoginProvider", "ProviderKey");
@@ -117,10 +126,10 @@ namespace NexusReader.Infrastructure.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{ {
b.Property<string>("UserId") b.Property<string>("UserId")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("RoleId") b.Property<string>("RoleId")
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("UserId", "RoleId"); b.HasKey("UserId", "RoleId");
@@ -132,16 +141,16 @@ namespace NexusReader.Infrastructure.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{ {
b.Property<string>("UserId") b.Property<string>("UserId")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("LoginProvider") b.Property<string>("LoginProvider")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("Value") b.Property<string>("Value")
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name"); b.HasKey("UserId", "LoginProvider", "Name");
@@ -152,34 +161,34 @@ namespace NexusReader.Infrastructure.Migrations
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("uuid");
b.Property<DateTime>("AddedDate") b.Property<DateTime>("AddedDate")
.HasColumnType("TEXT"); .HasColumnType("timestamp with time zone");
b.Property<string>("Author") b.Property<string>("Author")
.IsRequired() .IsRequired()
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("character varying(255)");
b.Property<string>("CoverUrl") b.Property<string>("CoverUrl")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("FilePath") b.Property<string>("FilePath")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<DateTime?>("LastReadDate") b.Property<DateTime?>("LastReadDate")
.HasColumnType("TEXT"); .HasColumnType("timestamp with time zone");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("character varying(255)");
b.Property<string>("UserId") b.Property<string>("UserId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
@@ -191,67 +200,67 @@ namespace NexusReader.Infrastructure.Migrations
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<int>("AITokenLimit") b.Property<int>("AITokenLimit")
.HasColumnType("INTEGER"); .HasColumnType("integer");
b.Property<int>("AITokensUsed") b.Property<int>("AITokensUsed")
.HasColumnType("INTEGER"); .HasColumnType("integer");
b.Property<int>("AccessFailedCount") b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER"); .HasColumnType("integer");
b.Property<string>("ConcurrencyStamp") b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("CurrentPlan") b.Property<string>("CurrentPlan")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("Email") b.Property<string>("Email")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed") b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER"); .HasColumnType("boolean");
b.Property<bool>("LockoutEnabled") b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER"); .HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd") b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT"); .HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail") b.Property<string>("NormalizedEmail")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName") b.Property<string>("NormalizedUserName")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("PhoneNumber") b.Property<string>("PhoneNumber")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed") b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER"); .HasColumnType("boolean");
b.Property<string>("SecurityStamp") b.Property<string>("SecurityStamp")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<Guid>("TenantId") b.Property<Guid>("TenantId")
.HasColumnType("TEXT"); .HasColumnType("uuid");
b.Property<bool>("TwoFactorEnabled") b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER"); .HasColumnType("boolean");
b.Property<string>("UserName") b.Property<string>("UserName")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.HasKey("Id"); b.HasKey("Id");
@@ -269,24 +278,24 @@ namespace NexusReader.Infrastructure.Migrations
{ {
b.Property<string>("ContentHash") b.Property<string>("ContentHash")
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("TEXT"); .HasColumnType("character varying(64)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT"); .HasColumnType("timestamp with time zone");
b.Property<string>("JsonData") b.Property<string>("JsonData")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ModelId") b.Property<string>("ModelId")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("character varying(50)");
b.Property<string>("PromptVersion") b.Property<string>("PromptVersion")
.IsRequired() .IsRequired()
.HasMaxLength(10) .HasMaxLength(10)
.HasColumnType("TEXT"); .HasColumnType("character varying(10)");
b.HasKey("ContentHash"); b.HasKey("ContentHash");
@@ -1,12 +1,13 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialIdentityAndEbooks : Migration public partial class InitialPostgres : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -15,10 +16,10 @@ namespace NexusReader.Infrastructure.Migrations
name: "AspNetRoles", name: "AspNetRoles",
columns: table => new columns: table => new
{ {
Id = table.Column<string>(type: "TEXT", nullable: false), Id = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true), Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true), NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true) ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
@@ -29,25 +30,25 @@ namespace NexusReader.Infrastructure.Migrations
name: "AspNetUsers", name: "AspNetUsers",
columns: table => new columns: table => new
{ {
Id = table.Column<string>(type: "TEXT", nullable: false), Id = table.Column<string>(type: "text", nullable: false),
AITokenLimit = table.Column<int>(type: "INTEGER", nullable: false), AITokenLimit = table.Column<int>(type: "integer", nullable: false),
AITokensUsed = table.Column<int>(type: "INTEGER", nullable: false), AITokensUsed = table.Column<int>(type: "integer", nullable: false),
TenantId = table.Column<Guid>(type: "TEXT", nullable: false), TenantId = table.Column<Guid>(type: "uuid", nullable: false),
CurrentPlan = table.Column<string>(type: "TEXT", nullable: false), CurrentPlan = table.Column<string>(type: "text", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true), UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true), NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true), Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true), NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false), EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true), PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true), SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true), ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true), PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false), PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false), TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true), LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false), LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false) AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
@@ -58,11 +59,11 @@ namespace NexusReader.Infrastructure.Migrations
name: "SemanticKnowledgeCache", name: "SemanticKnowledgeCache",
columns: table => new columns: table => new
{ {
ContentHash = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false), ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
JsonData = table.Column<string>(type: "TEXT", nullable: false), JsonData = table.Column<string>(type: "text", nullable: false),
ModelId = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false), ModelId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
PromptVersion = table.Column<string>(type: "TEXT", maxLength: 10, nullable: false), PromptVersion = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false) CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
@@ -73,11 +74,11 @@ namespace NexusReader.Infrastructure.Migrations
name: "AspNetRoleClaims", name: "AspNetRoleClaims",
columns: table => new columns: table => new
{ {
Id = table.Column<int>(type: "INTEGER", nullable: false) Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Sqlite:Autoincrement", true), .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<string>(type: "TEXT", nullable: false), RoleId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true), ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true) ClaimValue = table.Column<string>(type: "text", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
@@ -94,11 +95,11 @@ namespace NexusReader.Infrastructure.Migrations
name: "AspNetUserClaims", name: "AspNetUserClaims",
columns: table => new columns: table => new
{ {
Id = table.Column<int>(type: "INTEGER", nullable: false) Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Sqlite:Autoincrement", true), .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "TEXT", nullable: false), UserId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true), ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true) ClaimValue = table.Column<string>(type: "text", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
@@ -115,10 +116,10 @@ namespace NexusReader.Infrastructure.Migrations
name: "AspNetUserLogins", name: "AspNetUserLogins",
columns: table => new columns: table => new
{ {
LoginProvider = table.Column<string>(type: "TEXT", nullable: false), LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false), ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true), ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false) UserId = table.Column<string>(type: "text", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
@@ -135,8 +136,8 @@ namespace NexusReader.Infrastructure.Migrations
name: "AspNetUserRoles", name: "AspNetUserRoles",
columns: table => new columns: table => new
{ {
UserId = table.Column<string>(type: "TEXT", nullable: false), UserId = table.Column<string>(type: "text", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false) RoleId = table.Column<string>(type: "text", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
@@ -159,10 +160,10 @@ namespace NexusReader.Infrastructure.Migrations
name: "AspNetUserTokens", name: "AspNetUserTokens",
columns: table => new columns: table => new
{ {
UserId = table.Column<string>(type: "TEXT", nullable: false), UserId = table.Column<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false), LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false), Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true) Value = table.Column<string>(type: "text", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
@@ -179,14 +180,14 @@ namespace NexusReader.Infrastructure.Migrations
name: "Ebooks", name: "Ebooks",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "TEXT", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false), Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Author = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false), Author = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
FilePath = table.Column<string>(type: "TEXT", nullable: false), FilePath = table.Column<string>(type: "text", nullable: false),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true), CoverUrl = table.Column<string>(type: "text", nullable: true),
AddedDate = table.Column<DateTime>(type: "TEXT", nullable: false), AddedDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastReadDate = table.Column<DateTime>(type: "TEXT", nullable: true), LastReadDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false) UserId = table.Column<string>(type: "text", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
@@ -0,0 +1,377 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260428185239_IncreaseHashLength")]
partial class IncreaseHashLength
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp without time zone");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp without time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<int>("AITokensUsed")
.HasColumnType("integer");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("CurrentPlan")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
{
b.Property<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.HasKey("ContentHash");
b.HasIndex("ContentHash")
.IsUnique();
b.ToTable("SemanticKnowledgeCache");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class IncreaseHashLength : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "SemanticKnowledgeCache",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<string>(
name: "ContentHash",
table: "SemanticKnowledgeCache",
type: "character varying(128)",
maxLength: 128,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<DateTime>(
name: "LastReadDate",
table: "Ebooks",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "AddedDate",
table: "Ebooks",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "SemanticKnowledgeCache",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AlterColumn<string>(
name: "ContentHash",
table: "SemanticKnowledgeCache",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldMaxLength: 128);
migrationBuilder.AlterColumn<DateTime>(
name: "LastReadDate",
table: "Ebooks",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "AddedDate",
table: "Ebooks",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
}
}
}
@@ -0,0 +1,420 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260429080302_AddQuizResults")]
partial class AddQuizResults
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp without time zone");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp without time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<int>("AITokensUsed")
.HasColumnType("integer");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("CurrentPlan")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp without time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.Property<int>("TotalQuestions")
.HasColumnType("integer");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("QuizResults");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
{
b.Property<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.HasKey("ContentHash");
b.HasIndex("ContentHash")
.IsUnique();
b.ToTable("SemanticKnowledgeCache");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("QuizResults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
b.Navigation("QuizResults");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddQuizResults : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "QuizResults",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<string>(type: "text", nullable: false),
Topic = table.Column<string>(type: "text", nullable: false),
Score = table.Column<int>(type: "integer", nullable: false),
TotalQuestions = table.Column<int>(type: "integer", nullable: false),
CompletedDate = table.Column<DateTime>(type: "timestamp without time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QuizResults", x => x.Id);
table.ForeignKey(
name: "FK_QuizResults_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_QuizResults_UserId",
table: "QuizResults",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "QuizResults");
}
}
}
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence; using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
@@ -15,24 +16,28 @@ namespace NexusReader.Infrastructure.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ConcurrencyStamp") b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("Name") b.Property<string>("Name")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.Property<string>("NormalizedName") b.Property<string>("NormalizedName")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.HasKey("Id"); b.HasKey("Id");
@@ -47,17 +52,19 @@ namespace NexusReader.Infrastructure.Migrations
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType") b.Property<string>("ClaimType")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ClaimValue") b.Property<string>("ClaimValue")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("RoleId") b.Property<string>("RoleId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
@@ -70,17 +77,19 @@ namespace NexusReader.Infrastructure.Migrations
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType") b.Property<string>("ClaimType")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ClaimValue") b.Property<string>("ClaimValue")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("UserId") b.Property<string>("UserId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
@@ -92,17 +101,17 @@ namespace NexusReader.Infrastructure.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{ {
b.Property<string>("LoginProvider") b.Property<string>("LoginProvider")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ProviderKey") b.Property<string>("ProviderKey")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ProviderDisplayName") b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("UserId") b.Property<string>("UserId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey"); b.HasKey("LoginProvider", "ProviderKey");
@@ -114,10 +123,10 @@ namespace NexusReader.Infrastructure.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{ {
b.Property<string>("UserId") b.Property<string>("UserId")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("RoleId") b.Property<string>("RoleId")
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("UserId", "RoleId"); b.HasKey("UserId", "RoleId");
@@ -129,16 +138,16 @@ namespace NexusReader.Infrastructure.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{ {
b.Property<string>("UserId") b.Property<string>("UserId")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("LoginProvider") b.Property<string>("LoginProvider")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("Value") b.Property<string>("Value")
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name"); b.HasKey("UserId", "LoginProvider", "Name");
@@ -149,34 +158,34 @@ namespace NexusReader.Infrastructure.Migrations
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("uuid");
b.Property<DateTime>("AddedDate") b.Property<DateTime>("AddedDate")
.HasColumnType("TEXT"); .HasColumnType("timestamp without time zone");
b.Property<string>("Author") b.Property<string>("Author")
.IsRequired() .IsRequired()
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("character varying(255)");
b.Property<string>("CoverUrl") b.Property<string>("CoverUrl")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("FilePath") b.Property<string>("FilePath")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<DateTime?>("LastReadDate") b.Property<DateTime?>("LastReadDate")
.HasColumnType("TEXT"); .HasColumnType("timestamp without time zone");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("character varying(255)");
b.Property<string>("UserId") b.Property<string>("UserId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
@@ -188,67 +197,67 @@ namespace NexusReader.Infrastructure.Migrations
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<int>("AITokenLimit") b.Property<int>("AITokenLimit")
.HasColumnType("INTEGER"); .HasColumnType("integer");
b.Property<int>("AITokensUsed") b.Property<int>("AITokensUsed")
.HasColumnType("INTEGER"); .HasColumnType("integer");
b.Property<int>("AccessFailedCount") b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER"); .HasColumnType("integer");
b.Property<string>("ConcurrencyStamp") b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("CurrentPlan") b.Property<string>("CurrentPlan")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("Email") b.Property<string>("Email")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed") b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER"); .HasColumnType("boolean");
b.Property<bool>("LockoutEnabled") b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER"); .HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd") b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT"); .HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail") b.Property<string>("NormalizedEmail")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName") b.Property<string>("NormalizedUserName")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("PhoneNumber") b.Property<string>("PhoneNumber")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed") b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER"); .HasColumnType("boolean");
b.Property<string>("SecurityStamp") b.Property<string>("SecurityStamp")
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<Guid>("TenantId") b.Property<Guid>("TenantId")
.HasColumnType("TEXT"); .HasColumnType("uuid");
b.Property<bool>("TwoFactorEnabled") b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER"); .HasColumnType("boolean");
b.Property<string>("UserName") b.Property<string>("UserName")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("character varying(256)");
b.HasKey("Id"); b.HasKey("Id");
@@ -262,28 +271,58 @@ namespace NexusReader.Infrastructure.Migrations
b.ToTable("AspNetUsers", (string)null); b.ToTable("AspNetUsers", (string)null);
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp without time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.Property<int>("TotalQuestions")
.HasColumnType("integer");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("QuizResults");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
{ {
b.Property<string>("ContentHash") b.Property<string>("ContentHash")
.HasMaxLength(64) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT"); .HasColumnType("timestamp without time zone");
b.Property<string>("JsonData") b.Property<string>("JsonData")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("text");
b.Property<string>("ModelId") b.Property<string>("ModelId")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("character varying(50)");
b.Property<string>("PromptVersion") b.Property<string>("PromptVersion")
.IsRequired() .IsRequired()
.HasMaxLength(10) .HasMaxLength(10)
.HasColumnType("TEXT"); .HasColumnType("character varying(10)");
b.HasKey("ContentHash"); b.HasKey("ContentHash");
@@ -355,9 +394,22 @@ namespace NexusReader.Infrastructure.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("QuizResults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{ {
b.Navigation("Ebooks"); b.Navigation("Ebooks");
b.Navigation("QuizResults");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
@@ -15,8 +15,10 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" /> <PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
<PackageReference Include="Microsoft.Extensions.Resilience" Version="10.5.0" /> <PackageReference Include="Microsoft.Extensions.Resilience" Version="10.5.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Polly" Version="8.6.6" /> <PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" /> <PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="Stripe.net" Version="51.1.0" />
<PackageReference Include="VersOne.Epub" Version="3.3.6" /> <PackageReference Include="VersOne.Epub" Version="3.3.6" />
</ItemGroup> </ItemGroup>
@@ -12,6 +12,7 @@ public class AppDbContext : IdentityDbContext<NexusUser>
public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>(); public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>();
public DbSet<Ebook> Ebooks => Set<Ebook>(); public DbSet<Ebook> Ebooks => Set<Ebook>();
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -30,5 +31,13 @@ public class AppDbContext : IdentityDbContext<NexusUser>
.HasForeignKey(e => e.UserId) .HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<QuizResult>(entity =>
{
entity.HasOne(e => e.User)
.WithMany(u => u.QuizResults)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
} }
} }
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Persistence;
namespace NexusReader.Infrastructure.Services;
public class BillingService : IBillingService
{
private readonly AppDbContext _dbContext;
private readonly UserManager<NexusUser> _userManager;
public BillingService(AppDbContext dbContext, UserManager<NexusUser> userManager)
{
_dbContext = dbContext;
_userManager = userManager;
}
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
{
var user = await _userManager.FindByEmailAsync(customerEmail);
if (user == null) return false;
// Map Stripe Product IDs to Nexus Plans
// These IDs would typically come from configuration
if (stripeProductId.Contains("pro"))
{
user.CurrentPlan = "Pro";
user.AITokenLimit = 50000;
}
else if (stripeProductId.Contains("basic"))
{
user.CurrentPlan = "Basic";
user.AITokenLimit = 10000;
}
await _userManager.UpdateAsync(user);
return true;
}
public async Task<bool> HandleSubscriptionDeletedAsync(string customerEmail)
{
var user = await _userManager.FindByEmailAsync(customerEmail);
if (user == null) return false;
user.CurrentPlan = "Free";
user.AITokenLimit = 1000; // Reset to free limit
await _userManager.UpdateAsync(user);
return true;
}
}
+30 -27
View File
@@ -9,41 +9,44 @@ public static class MauiProgram
{ {
public static MauiApp CreateMauiApp() public static MauiApp CreateMauiApp()
{ {
var builder = MauiApp.CreateBuilder(); try
builder {
.UseMauiApp<App>() var builder = MauiApp.CreateBuilder();
.ConfigureFonts(fonts => builder
{ .UseMauiApp<App>();
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddMauiBlazorWebView(); builder.Services.AddMauiBlazorWebView();
#if DEBUG #if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools(); builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug(); builder.Logging.AddDebug();
#endif #endif
// Infrastructure // Minimal Infrastructure
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>(); builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>(); builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
// Identity // Minimal Identity (Safe Mode)
builder.Services.AddScoped<IIdentityService, IdentityService>(); builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>(); builder.Services.AddScoped<Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider>(sp =>
builder.Services.AddScoped<Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
sp.GetRequiredService<NexusAuthenticationStateProvider>()); builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();
// Network // Basic Network
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://localhost:5000") }); // Update with real API URL later builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://10.0.2.2:5000") });
// Shared UI State // UI State
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
return builder.Build(); return builder.Build();
}
catch (Exception ex)
{
// This might help the debugger catch the exception more reliably
System.Diagnostics.Debug.WriteLine($"MAUI Startup Error: {ex}");
throw;
}
} }
} }
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.nexus.ereader">
<application android:allowBackup="true" android:supportsRtl="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,19 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
using Android.Util;
namespace NexusReader.Maui;
[Activity(Theme = "@style/Maui.MainTheme.NoActionBar", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
private const string Tag = "NEXUS_MAUI";
protected override void OnCreate(Bundle? savedInstanceState)
{
Log.Debug(Tag, "MainActivity.OnCreate starting...");
base.OnCreate(savedInstanceState);
Log.Debug(Tag, "MainActivity.OnCreate completed");
}
}
@@ -0,0 +1,34 @@
using Android.App;
using Android.Runtime;
using Android.Util;
namespace NexusReader.Maui;
[Application]
public class MainApplication : MauiApplication
{
private const string Tag = "NEXUS_MAUI";
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
Log.Debug(Tag, "MainApplication constructor called");
}
protected override MauiApp CreateMauiApp()
{
Log.Debug(Tag, "CreateMauiApp starting...");
try
{
var app = MauiProgram.CreateMauiApp();
Log.Debug(Tag, "CreateMauiApp successful");
return app;
}
catch (Exception ex)
{
Log.Error(Tag, $"CreateMauiApp FAILED: {ex.Message}");
Log.Error(Tag, ex.StackTrace);
throw;
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#DFD8F7</color>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<style name="Maui.MainTheme.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowContentOverlay">@null</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>
@@ -6,7 +6,7 @@
<Style TargetType="Label"> <Style TargetType="Label">
<Setter Property="TextColor" Value="{StaticResource Gray100}" /> <Setter Property="TextColor" Value="{StaticResource Gray100}" />
<Setter Property="FontFamily" Value="OpenSansRegular" /> <!-- <Setter Property="FontFamily" Value="OpenSansRegular" /> -->
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
</Style> </Style>
@@ -0,0 +1,110 @@
using FluentResults;
using Microsoft.Maui.Storage;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Maui.Services;
public class MauiStorageService : INativeStorageService
{
public Result SaveString(string key, string value)
{
try
{
Preferences.Default.Set(key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result<string?> GetString(string key)
{
try
{
return Result.Ok(Preferences.Default.Get<string?>(key, null));
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result SaveBool(string key, bool value)
{
try
{
Preferences.Default.Set(key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result<bool> GetBool(string key, bool defaultValue = false)
{
try
{
return Result.Ok(Preferences.Default.Get(key, defaultValue));
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result Remove(string key)
{
try
{
Preferences.Default.Remove(key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result> SaveSecureString(string key, string value)
{
try
{
await SecureStorage.Default.SetAsync(key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result<string?>> GetSecureString(string key)
{
try
{
var value = await SecureStorage.Default.GetAsync(key);
return Result.Ok(value);
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result RemoveSecure(string key)
{
try
{
SecureStorage.Default.Remove(key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
}
@@ -28,6 +28,18 @@
case "trash": case "trash":
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" /> <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
break; break;
case "mail":
<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
break;
case "lock":
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
break;
case "eye":
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" />
break;
case "eye-off":
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
break;
default: default:
<!-- Fallback circle --> <!-- Fallback circle -->
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -66,7 +66,7 @@
await LoadChapterAsync(NavigationService.CurrentChapterIndex); await LoadChapterAsync(NavigationService.CurrentChapterIndex);
} }
private async void OnNavigationChanged() private async Task OnNavigationChanged()
{ {
_isJsInitialized = false; _isJsInitialized = false;
_selectedText = string.Empty; _selectedText = string.Empty;
@@ -166,7 +166,7 @@
NavigationService.UpdateMetadata(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle); NavigationService.UpdateMetadata(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
// Trigger full page graph generation after loading // Trigger full page graph generation after loading
_ = Coordinator.ProcessFullPageAsync(GetFullPageContent()); await Coordinator.ProcessFullPageAsync(GetFullPageContent());
} }
else else
{ {
@@ -33,7 +33,13 @@
@code { @code {
protected override void OnInitialized() protected override void OnInitialized()
{ {
NavigationService.OnNavigationChanged += StateHasChanged; NavigationService.OnNavigationChanged += HandleNavigationChanged;
}
private Task HandleNavigationChanged()
{
StateHasChanged();
return Task.CompletedTask;
} }
private int CalculateProgress() private int CalculateProgress()
@@ -44,6 +50,6 @@
public void Dispose() public void Dispose()
{ {
NavigationService.OnNavigationChanged -= StateHasChanged; NavigationService.OnNavigationChanged -= HandleNavigationChanged;
} }
} }
@@ -0,0 +1,16 @@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
if (string.IsNullOrWhiteSpace(returnUrl))
{
NavigationManager.NavigateTo("/account/login");
}
else
{
NavigationManager.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
}
}
@@ -0,0 +1,24 @@
@inherits LayoutComponentBase
<div class="nexus-auth-shell">
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/css/nexus-auth.css" />
@Body
</div>
<style>
.nexus-auth-shell {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #121418 !important;
display: flex;
justify-content: center;
align-items: center;
z-index: 99999;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
@@ -21,36 +21,33 @@
<div class="resizer" id="sidebar-resizer"></div> <div class="resizer" id="sidebar-resizer"></div>
<div class="intelligence-sidebar"> <AuthorizeView>
<IntelligenceToolbar /> <Authorized>
<div class="intelligence-content"> <div class="intelligence-sidebar">
<div class="intelligence-header"> <IntelligenceToolbar />
<div class="ai-title"> <div class="intelligence-content">
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" /> <div class="intelligence-header">
<span>Asystent AI</span> <div class="ai-title">
</div> <NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span>
</div>
<AuthorizeView>
<Authorized>
<div class="user-profile"> <div class="user-profile">
<span class="user-email">@context.User.Identity?.Name</span> <span class="user-email">@context.User.Identity?.Name</span>
<button class="logout-btn" @onclick="HandleLogout">Logout</button> <button class="logout-btn" @onclick="HandleLogout">Logout</button>
</div> </div>
</Authorized>
<NotAuthorized>
<a href="/account/login" class="login-link">Login</a>
</NotAuthorized>
</AuthorizeView>
<button class="close-btn">×</button> <button class="close-btn">×</button>
</div> </div>
<div class="intelligence-scroll-area"> <div class="intelligence-scroll-area">
<KnowledgeGraph /> <KnowledgeGraph />
<KnowledgeCheck /> <KnowledgeCheck />
</div>
</div>
</div> </div>
</div> </Authorized>
</div> </AuthorizeView>
</div> </div>
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup> </PropertyGroup>
@@ -1,69 +1,83 @@
@page "/account/login" @page "/account/login"
@layout AuthLayout
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="login-container"> <div class="login-page-container">
<div class="login-card"> <div class="mesh-bg"></div>
<div class="login-header">
<h1>NEXUS<span>AI</span></h1> <div class="auth-card">
<p>Welcome back, Reader.</p> <div class="auth-header">
<div class="logo-box">
<NexusIcon Name="robot" Size="48" />
</div>
<h1 class="app-name">E-Czytnik <span>AI</span></h1>
<p class="auth-subtitle">Zaloguj się do E-Czytnika AI</p>
</div> </div>
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin"> <div class="social-auth">
<button type="button" class="btn-google-auth" @onclick="HandleGoogleLogin">
<img src="https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png"
alt="Google"
style="width: 20px !important; height: 20px !important; flex-shrink: 0;" />
<span>Zaloguj się przez Google</span>
</button>
</div>
<div class="auth-divider">
<span>lub</span>
</div>
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="form-group"> <div class="field-group">
<label for="email">Email</label> <div class="field-icon">
<InputText id="email" @bind-Value="_loginModel.Email" class="form-control" placeholder="reader@nexus.ai" /> <NexusIcon Name="mail" Size="18" />
<ValidationMessage For="@(() => _loginModel.Email)" />
</div>
<div class="form-group">
<label for="password">Password</label>
<InputText id="password" type="password" @bind-Value="_loginModel.Password" class="form-control" placeholder="••••••••" />
<ValidationMessage For="@(() => _loginModel.Password)" />
</div>
<div class="form-options">
<div class="remember-me">
<InputCheckbox id="remember" @bind-Value="_loginModel.RememberMe" />
<label for="remember">Remember me</label>
</div> </div>
<a href="/account/forgot-password" class="forgot-link">Forgot password?</a> <InputText id="email" @bind-Value="_loginModel.Email" placeholder="E-mail" class="field-input" />
</div> </div>
<ValidationMessage For="@(() => _loginModel.Email)" class="auth-validation" />
<div class="field-group">
<div class="field-icon">
<NexusIcon Name="lock" Size="18" />
</div>
<InputText id="password" type="@(_showPassword ? "text" : "password")" @bind-Value="_loginModel.Password" placeholder="Hasło" class="field-input" />
<button type="button" class="toggle-visibility" @onclick="TogglePassword">
<NexusIcon Name="@(_showPassword ? "eye-off" : "eye")" Size="18" />
</button>
</div>
<ValidationMessage For="@(() => _loginModel.Password)" class="auth-validation" />
@if (!string.IsNullOrEmpty(_errorMessage)) @if (!string.IsNullOrEmpty(_errorMessage))
{ {
<div class="error-banner"> <div class="auth-error">@_errorMessage</div>
@_errorMessage
</div>
} }
<button type="submit" class="btn-login" disabled="@_isSubmitting"> <button type="submit" class="btn-submit-auth" disabled="@_isSubmitting">
@if (_isSubmitting) @if (_isSubmitting)
{ {
<span class="spinner"></span> <div class="auth-loader"></div>
} }
else else
{ {
<span>Login</span> <span>Zaloguj się</span>
} }
</button> </button>
<div class="separator">
<span>OR</span>
</div>
<button type="button" class="btn-google" @onclick="HandleGoogleLogin">
<img src="https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png" alt="Google" />
Continue with Google
</button>
</EditForm> </EditForm>
<div class="login-footer"> <div class="auth-footer">
<p>Don't have an account? <a href="/account/register">Create one</a></p> <a href="/account/forgot-password" class="auth-link">Zapomniałem hasła?</a>
<p class="auth-switch">Nie masz konta? <a href="/account/register">Zarejestruj się</a></p>
</div>
<div class="auth-legal">
Korzystając z usługi, akceptujesz <a href="/terms">Regulamin</a> i <a href="/privacy">Politykę Prywatności</a>
</div> </div>
</div> </div>
</div> </div>
@@ -72,6 +86,7 @@
private LoginModel _loginModel = new(); private LoginModel _loginModel = new();
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
private bool _showPassword;
private async Task HandleLogin() private async Task HandleLogin()
{ {
@@ -81,30 +96,15 @@
try try
{ {
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password); var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password);
if (success) if (success) NavigationManager.NavigateTo("/");
{ else _errorMessage = "Nieprawidłowy e-mail lub hasło.";
NavigationManager.NavigateTo("/");
}
else
{
_errorMessage = "Invalid email or password.";
}
}
catch (Exception)
{
_errorMessage = "An error occurred during login. Please try again.";
}
finally
{
_isSubmitting = false;
} }
catch (Exception) { _errorMessage = "Wystąpił błąd logowania."; }
finally { _isSubmitting = false; }
} }
private void HandleGoogleLogin() private void HandleGoogleLogin() => NavigationManager.NavigateTo("identity/login/google", forceLoad: true);
{ private void TogglePassword() => _showPassword = !_showPassword;
// Redirect to external login endpoint
NavigationManager.NavigateTo("identity/login/google", forceLoad: true);
}
public class LoginModel public class LoginModel
{ {
@@ -114,7 +114,5 @@
[System.ComponentModel.DataAnnotations.Required] [System.ComponentModel.DataAnnotations.Required]
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
} }
} }
@@ -1,223 +0,0 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #0a0a0a;
background-image: radial-gradient(circle at 50% 50%, #1a1a1a 0%, #0a0a0a 100%);
font-family: 'Inter', sans-serif;
color: #e0e0e0;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2.5rem;
background: rgba(20, 20, 20, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.login-header {
text-align: center;
margin-bottom: 2.5rem;
}
.login-header h1 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.05em;
margin: 0;
color: #ffffff;
}
.login-header h1 span {
color: #39ff14; /* Neon Green */
text-shadow: 0 0 10px rgba(57, 255, 20, 0.5);
}
.login-header p {
color: #888;
margin-top: 0.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: #bbb;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
background: #151515;
border: 1px solid #2a2a2a;
border-radius: 0.75rem;
color: #fff;
font-size: 1rem;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #39ff14;
box-shadow: 0 0 0 4px rgba(57, 255, 20, 0.1);
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.remember-me {
display: flex;
align-items: center;
gap: 0.5rem;
}
.remember-me input {
accent-color: #39ff14;
}
.forgot-link {
color: #888;
text-decoration: none;
transition: color 0.2s;
}
.forgot-link:hover {
color: #39ff14;
}
.btn-login {
width: 100%;
padding: 0.875rem;
background: #39ff14;
color: #000;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
justify-content: center;
align-items: center;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(57, 255, 20, 0.4);
background: #32e612;
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.error-banner {
background: rgba(255, 50, 50, 0.1);
border: 1px solid rgba(255, 50, 50, 0.2);
color: #ff5555;
padding: 0.75rem;
border-radius: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1.5rem;
text-align: center;
}
.login-footer {
margin-top: 2rem;
text-align: center;
font-size: 0.875rem;
color: #888;
}
.login-footer a {
color: #39ff14;
text-decoration: none;
font-weight: 500;
}
.login-footer a:hover {
text-decoration: underline;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.separator {
display: flex;
align-items: center;
text-align: center;
margin: 1.5rem 0;
color: #555;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
}
.separator::before,
.separator::after {
content: '';
flex: 1;
border-bottom: 1px solid #2a2a2a;
}
.separator span {
padding: 0 1rem;
}
.btn-google {
width: 100%;
padding: 0.875rem;
background: #1a1a1a;
color: #fff;
border: 1px solid #333;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
}
.btn-google img {
width: 20px;
height: 20px;
}
.btn-google:hover {
background: #252525;
border-color: #444;
}
@@ -1,54 +1,101 @@
@page "/account/profile" @page "/account/profile"
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms
@attribute [Authorize] @attribute [Authorize]
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="profile-container"> <div class="profile-dashboard">
<div class="profile-card"> <div class="mesh-overlay"></div>
@if (_profile == null)
{ @if (_profile == null)
<div class="loading-state"> {
<div class="spinner"></div> <div class="loading-overlay">
<p>Fetching your Nexus profile...</p> <div class="nexus-loader"></div>
</div> <p>Ładowanie Twojego profilu...</p>
} </div>
else }
{ else
<div class="profile-header"> {
<div class="user-avatar"> <div class="dashboard-content">
@(string.IsNullOrEmpty(_profile.Email) ? "?" : _profile.Email[0].ToString().ToUpper()) <header class="dashboard-header">
<div class="user-meta">
<div class="user-avatar">
@(_profile.Email[0].ToString().ToUpper())
</div>
<div class="user-info">
<h1>@_profile.Email</h1>
<div class="plan-info">
<span class="badge @(_profile.CurrentPlan.ToLower())">@_profile.CurrentPlan Plan</span>
<span class="tenant-id">ID: @_profile.TenantId.ToString()[..8]...</span>
</div>
</div>
</div> </div>
<h2>@_profile.Email</h2> <div class="header-actions">
<div class="plan-badge @(_profile.CurrentPlan.ToLower())"> <button class="btn-logout" @onclick="HandleLogout">
@_profile.CurrentPlan Plan <NexusIcon Name="lock" Size="16" />
Wyloguj się
</button>
</div>
</header>
<div class="stats-grid">
<!-- AI Token Card -->
<div class="stat-card usage-card">
<div class="card-icon">
<NexusIcon Name="robot" Size="24" />
</div>
<div class="card-info">
<h3>Wykorzystanie AI</h3>
<div class="token-numbers">
<span class="tokens-used">@_profile.AITokensUsed</span>
<span class="tokens-limit">/ @_profile.AITokenLimit tokenów</span>
</div>
<div class="usage-bar">
<div class="usage-fill" style="width: @(CalculateProgress())%"></div>
</div>
<p class="usage-desc">Limit odnawia się w następnym cyklu rozliczeniowym.</p>
</div>
</div>
<!-- Learning Progress Card -->
<div class="stat-card learning-card">
<div class="card-icon">
<NexusIcon Name="mail" Size="24" />
</div>
<div class="card-info">
<h3>Aktywna Nauka</h3>
<div class="learning-metrics">
<div class="metric">
<span class="label">Średni wynik quizów</span>
<span class="value">@_profile.AverageQuizScore%</span>
</div>
<div class="metric">
<span class="label">Ostatnio czytane</span>
<span class="value truncate">@_profile.LastReadBookTitle</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="profile-stats"> <section class="subscription-section">
<div class="stat-group"> <div class="section-card">
<div class="stat-header"> <div class="section-info">
<span>AI Token Usage</span> <h2>Zarządzaj subskrypcją</h2>
<span class="usage-count">@_profile.AITokensUsed / @_profile.AITokenLimit</span> <p>Zmień swój plan, aby zwiększyć limit tokenów AI i odblokować funkcje premium.</p>
</div> </div>
<div class="progress-bar"> <button class="btn-upgrade" @onclick="HandleUpgrade">
<div class="progress-fill" style="width: @(CalculateProgress())%"></div> Przejdź do panelu płatności
</div> </button>
<p class="stat-footer">Resetting on your next billing date.</p>
</div> </div>
</div> </section>
</div>
}
<div class="profile-actions"> <div class="decoration-star top-left">✦</div>
<button class="btn-primary" @onclick="HandleUpgrade"> <div class="decoration-star bottom-right">✦</div>
Manage Subscription
</button>
<button class="btn-outline" @onclick="HandleLogout">
Sign Out
</button>
</div>
}
</div>
</div> </div>
@code { @code {
@@ -1,173 +1,256 @@
.profile-container { .profile-dashboard {
position: relative;
width: 100%;
min-height: 100vh;
background-color: #121418;
color: white;
font-family: 'Inter', sans-serif;
padding: 60px 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; overflow-x: hidden;
min-height: 100vh;
background-color: #0a0a0a;
padding: 2rem;
} }
.profile-card { .mesh-overlay {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background-image: radial-gradient(circle at 2px 2px, rgba(255,255,255,0.02) 1px, transparent 0);
background-size: 40px 40px;
z-index: 1;
}
.dashboard-content {
position: relative;
width: 100%; width: 100%;
max-width: 500px; max-width: 1000px;
background: rgba(20, 20, 20, 0.8); z-index: 10;
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 2rem;
padding: 3rem;
box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.6);
} }
.profile-header { .dashboard-header {
text-align: center; display: flex;
margin-bottom: 3rem; justify-content: space-between;
align-items: center;
margin-bottom: 48px;
padding: 0 10px;
}
.user-meta {
display: flex;
align-items: center;
gap: 24px;
} }
.user-avatar { .user-avatar {
width: 80px; width: 80px;
height: 80px; height: 80px;
background: linear-gradient(135deg, #39ff14 0%, #1a8a0a 100%); background: linear-gradient(135deg, #44ff77 0%, #2ecc71 100%);
border-radius: 50%; border-radius: 24px;
margin: 0 auto 1.5rem;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
font-size: 2rem; font-size: 2.5rem;
font-weight: 700; font-weight: 800;
color: #000; color: #000;
box-shadow: 0 0 20px rgba(57, 255, 20, 0.3); box-shadow: 0 10px 30px rgba(68, 255, 119, 0.2);
} }
.profile-header h2 { .user-info h1 {
font-size: 1.5rem; font-size: 1.8rem;
font-weight: 700; font-weight: 700;
margin: 0 0 0.75rem; margin: 0 0 8px;
letter-spacing: -0.02em;
} }
.plan-badge { .plan-info {
display: inline-block; display: flex;
padding: 0.4rem 1rem; align-items: center;
border-radius: 2rem; gap: 12px;
}
.badge {
padding: 4px 12px;
border-radius: 8px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.plan-badge.free { .badge.pro { background: rgba(68, 255, 119, 0.1); color: #44ff77; border: 1px solid rgba(68, 255, 119, 0.2); }
background: rgba(255, 255, 255, 0.1); .badge.free { background: rgba(255, 255, 255, 0.05); color: #888; border: 1px solid rgba(255, 255, 255, 0.1); }
color: #888;
}
.plan-badge.pro { .tenant-id { font-size: 0.8rem; color: #555; }
background: rgba(57, 255, 20, 0.1);
color: #39ff14;
border: 1px solid rgba(57, 255, 20, 0.2);
}
.profile-stats { .btn-logout {
margin-bottom: 3rem; display: flex;
} align-items: center;
gap: 8px;
.stat-group { padding: 10px 18px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
padding: 1.5rem; border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05); color: #888;
} font-size: 0.9rem;
.stat-header {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 1rem;
color: #bbb;
}
.usage-count {
color: #fff;
font-weight: 700;
}
.progress-bar {
width: 100%;
height: 8px;
background: #1a1a1a;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-fill {
height: 100%;
background: #39ff14;
box-shadow: 0 0 10px rgba(57, 255, 20, 0.5);
border-radius: 4px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.stat-footer {
font-size: 0.75rem;
color: #666;
margin: 0;
}
.profile-actions {
display: flex;
flex-direction: column;
gap: 1rem;
}
.btn-primary {
width: 100%;
padding: 1rem;
background: #39ff14;
color: #000;
border: none;
border-radius: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(57, 255, 20, 0.3);
}
.btn-outline {
width: 100%;
padding: 1rem;
background: transparent;
color: #ff5555;
border: 1px solid rgba(255, 85, 85, 0.2);
border-radius: 1rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.btn-outline:hover { .btn-logout:hover {
background: rgba(255, 85, 85, 0.05); background: rgba(255, 77, 77, 0.05);
border-color: rgba(255, 85, 85, 0.4); border-color: rgba(255, 77, 77, 0.2);
color: #ff4d4d;
} }
.loading-state { .stats-grid {
text-align: center; display: grid;
padding: 4rem 0; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
margin-bottom: 48px;
} }
.spinner { .stat-card {
width: 40px; background: #1c1f24;
height: 40px; border: 1px solid rgba(255, 255, 255, 0.08);
border: 4px solid rgba(57, 255, 20, 0.1); border-radius: 28px;
border-top-color: #39ff14; padding: 32px;
display: flex;
gap: 24px;
transition: transform 0.3s ease;
}
.stat-card:hover { transform: translateY(-5px); }
.card-icon {
width: 56px;
height: 56px;
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
display: flex;
justify-content: center;
align-items: center;
color: #44ff77;
flex-shrink: 0;
}
.card-info h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 16px;
color: #e0e0e0;
}
.token-numbers {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 12px;
}
.tokens-used { font-size: 2rem; font-weight: 800; color: white; }
.tokens-limit { font-size: 1rem; color: #555; font-weight: 500; }
.usage-bar {
width: 100%;
height: 8px;
background: #15181c;
border-radius: 10px;
overflow: hidden;
margin-bottom: 12px;
}
.usage-fill {
height: 100%;
background: #44ff77;
box-shadow: 0 0 15px rgba(68, 255, 119, 0.3);
border-radius: 10px;
}
.usage-desc { font-size: 0.8rem; color: #555; margin: 0; }
.learning-metrics {
display: flex;
flex-direction: column;
gap: 20px;
}
.metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric .label { font-size: 0.85rem; color: #666; font-weight: 500; }
.metric .value { font-size: 1.2rem; font-weight: 700; color: white; }
.metric .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
.subscription-section { margin-top: 48px; }
.section-card {
background: linear-gradient(90deg, #1c1f24 0%, #23272e 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
padding: 40px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 40px;
}
.section-info h2 { font-size: 1.5rem; font-weight: 700; margin: 0 0 12px; }
.section-info p { color: #888; margin: 0; line-height: 1.6; max-width: 500px; }
.btn-upgrade {
padding: 16px 32px;
background: #44ff77;
border: none;
border-radius: 16px;
color: #000;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-upgrade:hover {
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(68, 255, 119, 0.2);
}
.loading-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.nexus-loader {
width: 48px;
height: 48px;
border: 3px solid rgba(68, 255, 119, 0.1);
border-top-color: #44ff77;
border-radius: 50%; border-radius: 50%;
margin: 0 auto 1.5rem;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin { to { transform: rotate(360deg); } }
to { transform: rotate(360deg); }
.decoration-star {
position: absolute;
font-size: 48px;
color: rgba(255, 255, 255, 0.03);
z-index: 1;
}
.top-left { top: 40px; left: 40px; }
.bottom-right { bottom: 40px; right: 40px; }
@media (max-width: 768px) {
.dashboard-header { flex-direction: column; align-items: flex-start; gap: 24px; }
.header-actions { width: 100%; }
.btn-logout { width: 100%; justify-content: center; }
.stats-grid { grid-template-columns: 1fr; }
.section-card { flex-direction: column; text-align: center; }
} }
@@ -1,58 +1,70 @@
@page "/account/register" @page "/account/register"
@layout AuthLayout
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="login-container"> <div class="login-page-container">
<div class="login-card"> <div class="mesh-bg"></div>
<div class="login-header">
<h1>NEXUS<span>AI</span></h1> <div class="auth-card">
<p>Join the future of reading.</p> <div class="auth-header">
<div class="logo-box">
<NexusIcon Name="robot" Size="48" />
</div>
<h1 class="app-name">Nexus <span>Reader</span></h1>
<p class="auth-subtitle">Utwórz nowe konto</p>
</div> </div>
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister"> <EditForm Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="form-group"> <div class="field-group">
<label for="email">Email</label> <div class="field-icon">
<InputText id="email" @bind-Value="_registerModel.Email" class="form-control" placeholder="reader@nexus.ai" /> <NexusIcon Name="mail" Size="18" />
<ValidationMessage For="@(() => _registerModel.Email)" /> </div>
<InputText id="email" @bind-Value="_registerModel.Email" placeholder="E-mail" class="field-input" />
</div> </div>
<ValidationMessage For="@(() => _registerModel.Email)" class="auth-validation" />
<div class="form-group"> <div class="field-group">
<label for="password">Password</label> <div class="field-icon">
<InputText id="password" type="password" @bind-Value="_registerModel.Password" class="form-control" placeholder="Min 8 chars, uppercase, digit" /> <NexusIcon Name="lock" Size="18" />
<ValidationMessage For="@(() => _registerModel.Password)" /> </div>
<InputText id="password" type="password" @bind-Value="_registerModel.Password" placeholder="Hasło" class="field-input" />
</div> </div>
<ValidationMessage For="@(() => _registerModel.Password)" class="auth-validation" />
<div class="form-group"> <div class="field-group">
<label for="confirm-password">Confirm Password</label> <div class="field-icon">
<InputText id="confirm-password" type="password" @bind-Value="_registerModel.ConfirmPassword" class="form-control" placeholder="••••••••" /> <NexusIcon Name="lock" Size="18" />
<ValidationMessage For="@(() => _registerModel.ConfirmPassword)" /> </div>
<InputText id="confirmPassword" type="password" @bind-Value="_registerModel.ConfirmPassword" placeholder="Potwierdź hasło" class="field-input" />
</div> </div>
<ValidationMessage For="@(() => _registerModel.ConfirmPassword)" class="auth-validation" />
@if (!string.IsNullOrEmpty(_errorMessage)) @if (!string.IsNullOrEmpty(_errorMessage))
{ {
<div class="error-banner"> <div class="auth-error">@_errorMessage</div>
@_errorMessage
</div>
} }
<button type="submit" class="btn-login" disabled="@_isSubmitting"> <button type="submit" class="btn-submit-auth" disabled="@_isSubmitting">
@if (_isSubmitting) @if (_isSubmitting)
{ {
<span class="spinner"></span> <div class="auth-loader"></div>
} }
else else
{ {
<span>Register</span> <span>Zarejestruj się</span>
} }
</button> </button>
</EditForm> </EditForm>
<div class="login-footer"> <div class="auth-footer">
<p>Already have an account? <a href="/account/login">Login here</a></p> <p class="auth-switch">Masz już konto? <a href="/account/login">Zaloguj się</a></p>
</div> </div>
</div> </div>
</div> </div>
@@ -64,12 +76,6 @@
private async Task HandleRegister() private async Task HandleRegister()
{ {
if (_registerModel.Password != _registerModel.ConfirmPassword)
{
_errorMessage = "Passwords do not match.";
return;
}
_isSubmitting = true; _isSubmitting = true;
_errorMessage = null; _errorMessage = null;
@@ -78,35 +84,27 @@
var success = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password); var success = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password);
if (success) if (success)
{ {
// Registration successful, redirect to login var loginSuccess = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password);
NavigationManager.NavigateTo("/account/login?registered=true"); if (loginSuccess) NavigationManager.NavigateTo("/");
} else NavigationManager.NavigateTo("/account/login");
else
{
_errorMessage = "Registration failed. Email might already be in use.";
} }
else { _errorMessage = "Rejestracja nie powiodła się."; }
} }
catch (Exception) catch (Exception) { _errorMessage = "Wystąpił błąd podczas rejestracji."; }
{ finally { _isSubmitting = false; }
_errorMessage = "An error occurred during registration. Please try again.";
}
finally
{
_isSubmitting = false;
}
} }
public class RegisterModel public class RegisterModel
{ {
[System.ComponentModel.DataAnnotations.Required] [System.ComponentModel.DataAnnotations.Required(ErrorMessage = "E-mail jest wymagany")]
[System.ComponentModel.DataAnnotations.EmailAddress] [System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Nieprawidłowy e-mail")]
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required] [System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Hasło jest wymagane")]
[System.ComponentModel.DataAnnotations.MinLength(8)] [System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Minimum 8 znaków")]
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required] [System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Hasła nie są identyczne")]
public string ConfirmPassword { get; set; } = string.Empty; public string ConfirmPassword { get; set; } = string.Empty;
} }
} }
@@ -1,174 +0,0 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #0a0a0a;
background-image: radial-gradient(circle at 50% 50%, #1a1a1a 0%, #0a0a0a 100%);
font-family: 'Inter', sans-serif;
color: #e0e0e0;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2.5rem;
background: rgba(20, 20, 20, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.login-header {
text-align: center;
margin-bottom: 2.5rem;
}
.login-header h1 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.05em;
margin: 0;
color: #ffffff;
}
.login-header h1 span {
color: #39ff14; /* Neon Green */
text-shadow: 0 0 10px rgba(57, 255, 20, 0.5);
}
.login-header p {
color: #888;
margin-top: 0.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: #bbb;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
background: #151515;
border: 1px solid #2a2a2a;
border-radius: 0.75rem;
color: #fff;
font-size: 1rem;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #39ff14;
box-shadow: 0 0 0 4px rgba(57, 255, 20, 0.1);
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.remember-me {
display: flex;
align-items: center;
gap: 0.5rem;
}
.remember-me input {
accent-color: #39ff14;
}
.forgot-link {
color: #888;
text-decoration: none;
transition: color 0.2s;
}
.forgot-link:hover {
color: #39ff14;
}
.btn-login {
width: 100%;
padding: 0.875rem;
background: #39ff14;
color: #000;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
justify-content: center;
align-items: center;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(57, 255, 20, 0.4);
background: #32e612;
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.error-banner {
background: rgba(255, 50, 50, 0.1);
border: 1px solid rgba(255, 50, 50, 0.2);
color: #ff5555;
padding: 0.75rem;
border-radius: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1.5rem;
text-align: center;
}
.login-footer {
margin-top: 2rem;
text-align: center;
font-size: 0.875rem;
color: #888;
}
.login-footer a {
color: #39ff14;
text-decoration: none;
font-weight: 500;
}
.login-footer a:hover {
text-decoration: underline;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -1,4 +1,5 @@
@page "/" @page "/"
@attribute [Authorize]
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IQuizStateService QuizState @inject IQuizStateService QuizState
+1 -1
View File
@@ -4,7 +4,7 @@
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)">
<NotAuthorized> <NotAuthorized>
<p role="alert">You are not authorized to access this resource. Please login.</p> <RedirectToLogin />
</NotAuthorized> </NotAuthorized>
</AuthorizeRouteView> </AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />
@@ -6,10 +6,10 @@ public interface IReaderNavigationService
int TotalChapters { get; } int TotalChapters { get; }
string ChapterTitle { get; } string ChapterTitle { get; }
event Action OnNavigationChanged; event Func<Task>? OnNavigationChanged;
void GoToChapter(int index); Task GoToChapter(int index);
void GoToNextChapter(); Task GoToNextChapter();
void GoToPreviousChapter(); Task GoToPreviousChapter();
void UpdateMetadata(int currentIndex, int totalChapters, string title); void UpdateMetadata(int currentIndex, int totalChapters, string title);
} }
@@ -9,6 +9,7 @@ public interface IIdentityService
Task<bool> LoginAsync(string email, string password); Task<bool> LoginAsync(string email, string password);
Task LogoutAsync(); Task LogoutAsync();
Task<UserProfile?> GetProfileAsync(); Task<UserProfile?> GetProfileAsync();
Task<bool> RefreshTokenAsync();
} }
public record UserProfile( public record UserProfile(
@@ -16,7 +17,9 @@ public record UserProfile(
int AITokenLimit, int AITokenLimit,
int AITokensUsed, int AITokensUsed,
string CurrentPlan, string CurrentPlan,
Guid TenantId); Guid TenantId,
int AverageQuizScore,
string LastReadBookTitle);
public class IdentityService : IIdentityService public class IdentityService : IIdentityService
{ {
@@ -24,6 +27,7 @@ public class IdentityService : IIdentityService
private readonly INativeStorageService _storageService; private readonly INativeStorageService _storageService;
private readonly NexusAuthenticationStateProvider _authStateProvider; private readonly NexusAuthenticationStateProvider _authStateProvider;
private const string TokenKey = "nexus_auth_token"; private const string TokenKey = "nexus_auth_token";
private const string RefreshTokenKey = "nexus_refresh_token";
public IdentityService( public IdentityService(
HttpClient httpClient, HttpClient httpClient,
@@ -51,6 +55,10 @@ public class IdentityService : IIdentityService
if (result != null && !string.IsNullOrEmpty(result.AccessToken)) if (result != null && !string.IsNullOrEmpty(result.AccessToken))
{ {
await _storageService.SaveSecureString(TokenKey, result.AccessToken); await _storageService.SaveSecureString(TokenKey, result.AccessToken);
if (!string.IsNullOrEmpty(result.RefreshToken))
{
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
}
_authStateProvider.NotifyUserAuthentication(result.AccessToken); _authStateProvider.NotifyUserAuthentication(result.AccessToken);
return true; return true;
} }
@@ -62,6 +70,7 @@ public class IdentityService : IIdentityService
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
_storageService.RemoveSecure(TokenKey); _storageService.RemoveSecure(TokenKey);
_storageService.RemoveSecure(RefreshTokenKey);
_authStateProvider.NotifyUserLogout(); _authStateProvider.NotifyUserLogout();
} }
@@ -77,6 +86,33 @@ public class IdentityService : IIdentityService
} }
} }
public async Task<bool> RefreshTokenAsync()
{
var result = await _storageService.GetSecureString(RefreshTokenKey);
var refreshToken = result.IsSuccess ? result.Value : null;
if (string.IsNullOrEmpty(refreshToken)) return false;
var response = await _httpClient.PostAsJsonAsync("identity/refresh", new { refreshToken });
if (response.IsSuccessStatusCode)
{
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken))
{
await _storageService.SaveSecureString(TokenKey, loginResult.AccessToken);
if (!string.IsNullOrEmpty(loginResult.RefreshToken))
{
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
}
_authStateProvider.NotifyUserAuthentication(loginResult.AccessToken);
return true;
}
}
return false;
}
private class LoginResponse private class LoginResponse
{ {
public string TokenType { get; set; } = string.Empty; public string TokenType { get; set; } = string.Empty;
@@ -1,3 +1,5 @@
using System.Linq;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public class ReaderNavigationService : IReaderNavigationService public class ReaderNavigationService : IReaderNavigationService
@@ -6,29 +8,29 @@ public class ReaderNavigationService : IReaderNavigationService
public int TotalChapters { get; private set; } = 1; public int TotalChapters { get; private set; } = 1;
public string ChapterTitle { get; private set; } = "Loading..."; public string ChapterTitle { get; private set; } = "Loading...";
public event Action? OnNavigationChanged; public event Func<Task>? OnNavigationChanged;
public void GoToChapter(int index) public async Task GoToChapter(int index)
{ {
if (index < 0 || index >= TotalChapters) return; if (index < 0 || index >= TotalChapters) return;
CurrentChapterIndex = index; CurrentChapterIndex = index;
OnNavigationChanged?.Invoke(); await NotifyNavigationChangedAsync();
} }
public void GoToNextChapter() public async Task GoToNextChapter()
{ {
if (CurrentChapterIndex < TotalChapters - 1) if (CurrentChapterIndex < TotalChapters - 1)
{ {
GoToChapter(CurrentChapterIndex + 1); await GoToChapter(CurrentChapterIndex + 1);
} }
} }
public void GoToPreviousChapter() public async Task GoToPreviousChapter()
{ {
if (CurrentChapterIndex > 0) if (CurrentChapterIndex > 0)
{ {
GoToChapter(CurrentChapterIndex - 1); await GoToChapter(CurrentChapterIndex - 1);
} }
} }
@@ -41,7 +43,21 @@ public class ReaderNavigationService : IReaderNavigationService
if (changed) if (changed)
{ {
OnNavigationChanged?.Invoke(); // Note: UpdateMetadata remains void, so we trigger notification fire-and-forget here
// but usually this is called during a render cycle where metadata is updated from a load.
_ = NotifyNavigationChangedAsync();
}
}
private async Task NotifyNavigationChangedAsync()
{
var handlers = OnNavigationChanged?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers.Cast<Func<Task>>())
{
await handler();
}
} }
} }
} }
@@ -2,7 +2,7 @@ using FluentResults;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.New.Services; namespace NexusReader.UI.Shared.Services;
public class WebStorageService : INativeStorageService public class WebStorageService : INativeStorageService
{ {
@@ -17,10 +17,6 @@ public class WebStorageService : INativeStorageService
{ {
try try
{ {
// Note: We can't use await in a non-async method,
// but for Blazor Server/WASM we usually want async.
// However, INativeStorageService has some non-async methods.
// We'll use InvokeVoidAsync and ignore the task if needed, or implement them properly.
_jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value); _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok(); return Result.Ok();
} }
@@ -32,9 +28,6 @@ public class WebStorageService : INativeStorageService
public Result<string?> GetString(string key) public Result<string?> GetString(string key)
{ {
// This is problematic for synchronous Blazor Server calls.
// But in InteractiveAuto/WASM it should be fine if called from async context.
// For simplicity and since we mostly care about the async ones for auth:
return Result.Fail("Use GetStringAsync or similar if available, or call from async context."); return Result.Fail("Use GetStringAsync or similar if available, or call from async context.");
} }
+2
View File
@@ -9,6 +9,8 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using NexusReader.UI.Shared @using NexusReader.UI.Shared
@using NexusReader.UI.Shared.Layout @using NexusReader.UI.Shared.Layout
@using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Components
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@@ -0,0 +1,98 @@
:root {
--nexus-primary: #44ff77;
--nexus-bg: #121418;
--nexus-card-bg: #1c1f24;
--nexus-border: rgba(255, 255, 255, 0.08);
--nexus-text-muted: #666;
--nexus-text-bright: #e2e8f0;
}
.login-page-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--nexus-bg);
color: white;
font-family: 'Inter', sans-serif;
}
.mesh-bg {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background-image: radial-gradient(circle at 2px 2px, rgba(255,255,255,0.03) 1px, transparent 0);
background-size: 40px 40px;
opacity: 0.5;
z-index: 1;
}
.auth-card {
position: relative;
width: 100%;
max-width: 420px;
padding: 40px;
background: var(--nexus-card-bg);
border: 1px solid var(--nexus-border);
border-radius: 24px;
box-shadow: 0 30px 60px rgba(0,0,0,0.4);
z-index: 10;
text-align: center;
}
.auth-header { margin-bottom: 24px; }
.logo-box { margin-bottom: 12px; color: white; }
.app-name { font-size: 1.5rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.app-name span { color: var(--nexus-primary); }
.auth-subtitle { font-size: 1.1rem; color: #bbb; margin-top: 8px; }
.btn-google-auth {
width: 100%; display: flex; justify-content: center; align-items: center; gap: 12px;
padding: 12px; background: transparent; border: 1px solid #3d424a; border-radius: 12px;
color: var(--nexus-text-bright); font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.2s;
}
.btn-google-auth:hover { background: rgba(255,255,255,0.05); }
.auth-divider { display: flex; align-items: center; margin: 20px 0; color: var(--nexus-text-muted); font-size: 0.8rem; }
.auth-divider::before, .auth-divider::after { content: ""; flex: 1; height: 1px; background: #2d3239; }
.auth-divider span { padding: 0 12px; }
.auth-form { display: flex; flex-direction: column; gap: 16px; }
.field-group { position: relative; display: flex; align-items: center; }
.field-icon { position: absolute; left: 16px; color: var(--nexus-text-muted); pointer-events: none; z-index: 5; }
.field-input {
width: 100% !important; padding: 14px 16px 14px 48px !important;
background: #15181c !important; border: 1px solid #2d3239 !important;
border-radius: 12px !important; color: white !important; font-size: 0.9rem !important;
outline: none !important; transition: border-color 0.2s;
}
.field-input:focus { border-color: var(--nexus-primary) !important; }
.toggle-visibility { position: absolute; right: 16px; background: none; border: none; color: var(--nexus-text-muted); cursor: pointer; padding: 4px; z-index: 5; }
.btn-submit-auth {
width: 100%; padding: 14px; background: var(--nexus-primary); border: none; border-radius: 12px;
color: #000; font-size: 0.95rem; font-weight: 700; cursor: pointer; transition: all 0.2s;
margin-top: 8px;
}
.btn-submit-auth:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(68, 255, 119, 0.2); }
.auth-footer { margin-top: 24px; display: flex; flex-direction: column; gap: 8px; }
.auth-link { color: var(--nexus-text-muted); text-decoration: none; font-size: 0.85rem; }
.auth-switch { color: var(--nexus-text-muted); font-size: 0.9rem; margin: 0; }
.auth-switch a { color: white; text-decoration: none; font-weight: 600; }
.auth-switch a:hover { color: var(--nexus-primary); }
.auth-legal { margin-top: 32px; font-size: 0.7rem; color: #444; line-height: 1.4; }
.auth-legal a { color: #555; }
.auth-validation { color: #ff4d4d; font-size: 0.75rem; text-align: left; margin-top: 4px; }
.auth-error { color: #ff4d4d; font-size: 0.85rem; text-align: center; }
.auth-loader { width: 18px; height: 18px; border: 2px solid rgba(0,0,0,0.1); border-radius: 50%; border-top-color: #000; animation: spin 0.8s linear infinite; margin: 0 auto; }
@keyframes spin { to { transform: rotate(360deg); } }
+13 -1
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services; using NexusReader.Web.Client.Services;
using NexusReader.UI.Shared.Services; using NexusReader.UI.Shared.Services;
@@ -8,7 +9,9 @@ using NexusReader.Infrastructure.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Platform & UI Services
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
@@ -16,8 +19,17 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>(); builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(); builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>(); builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
// Identity & Auth Services
builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
// AI & Content Services
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddApplication(); builder.Services.AddApplication();
+25 -5
View File
@@ -3,7 +3,6 @@ using NexusReader.Application;
using NexusReader.Infrastructure; using NexusReader.Infrastructure;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services; using NexusReader.Web.Client.Services;
using NexusReader.Web.New.Services;
using NexusReader.UI.Shared.Services; using NexusReader.UI.Shared.Services;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Persistence; using NexusReader.Infrastructure.Persistence;
@@ -14,6 +13,9 @@ using Microsoft.AspNetCore.Authorization;
using NexusReader.Infrastructure.Identity; using NexusReader.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using System.Security.Claims; using System.Security.Claims;
using NexusReader.Infrastructure.Services;
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -32,7 +34,7 @@ builder.Services.AddServerSideBlazor()
}); });
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, WebStorageService>(); builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
@@ -63,6 +65,9 @@ builder.Services.AddAuthorization(options =>
options.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); options.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
}); });
// Billing & Stripe
builder.Services.AddScoped<IBillingService, BillingService>();
// Authentication // Authentication
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>
{ {
@@ -80,6 +85,7 @@ builder.Services.AddIdentityApiEndpoints<NexusUser>()
builder.Services.ConfigureApplicationCookie(options => builder.Services.ConfigureApplicationCookie(options =>
{ {
options.LoginPath = "/account/login";
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30); options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true; options.SlidingExpiration = true;
@@ -220,21 +226,35 @@ app.MapGet("/identity/callback/google", async (
return Results.Redirect("/account/login?error=ProvisioningFailed"); return Results.Redirect("/account/login?error=ProvisioningFailed");
}); });
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager) => app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, AppDbContext dbContext) =>
{ {
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Results.Unauthorized(); if (userId == null) return Results.Unauthorized();
var nexusUser = await userManager.FindByIdAsync(userId); var nexusUser = await dbContext.Users
.Include(u => u.Ebooks)
.Include(u => u.QuizResults)
.FirstOrDefaultAsync(u => u.Id == userId);
if (nexusUser == null) return Results.NotFound(); if (nexusUser == null) return Results.NotFound();
var avgScore = nexusUser.QuizResults.Any()
? (int)nexusUser.QuizResults.Average(q => q.Percentage)
: 0;
var lastReadBook = nexusUser.Ebooks
.OrderByDescending(e => e.LastReadDate)
.FirstOrDefault()?.Title ?? "None";
return Results.Ok(new return Results.Ok(new
{ {
nexusUser.Email, nexusUser.Email,
nexusUser.AITokenLimit, nexusUser.AITokenLimit,
nexusUser.AITokensUsed, nexusUser.AITokensUsed,
nexusUser.CurrentPlan, nexusUser.CurrentPlan,
nexusUser.TenantId nexusUser.TenantId,
AverageQuizScore = avgScore,
LastReadBookTitle = lastReadBook
}); });
}).RequireAuthorization(); }).RequireAuthorization();
+6 -1
View File
@@ -1,4 +1,8 @@
{ {
"Stripe": {
"ApiKey": "sk_test_placeholder",
"WebhookSecret": "whsec_placeholder"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@@ -7,7 +11,8 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"SqliteConnection": "Data Source=nexus.db" "SqliteConnection": "Data Source=nexus.db",
"PostgresConnection": "Host=localhost;Database=nexus_db;Username=nexus_user;Password=nexus_password"
}, },
"Authentication": { "Authentication": {
"Google": { "Google": {