refactor: consolidate project structure by migrating authentication, identity, and shared UI components while removing legacy Web Client files.

This commit is contained in:
2026-04-28 20:23:40 +02:00
parent 131981992c
commit 10efed0369
124 changed files with 2822 additions and 2213 deletions
@@ -9,4 +9,8 @@ public interface INativeStorageService
Result SaveBool(string key, bool value);
Result<bool> GetBool(string key, bool defaultValue = false);
Result Remove(string key);
Task<Result> SaveSecureString(string key, string value);
Task<Result<string?>> GetSecureString(string key);
Result RemoveSecure(string key);
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
@@ -9,6 +9,8 @@
<PackageReference Include="Mapster" Version="10.0.7" />
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
</ItemGroup>
<PropertyGroup>
@@ -0,0 +1,47 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Security.Authorization;
public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
{
private readonly UserManager<NexusUser> _userManager;
public ProUserHandler(UserManager<NexusUser> userManager)
{
_userManager = userManager;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ProUserRequirement requirement)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return;
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return;
}
// Rule 1: Explicit Pro plan
if (user.CurrentPlan == "Pro")
{
context.Succeed(requirement);
return;
}
// Rule 2: Within Token Limits (SaaS logic)
if (user.AITokensUsed < user.AITokenLimit)
{
context.Succeed(requirement);
return;
}
}
}
@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization;
namespace NexusReader.Application.Security.Authorization;
/// <summary>
/// Requirement for users with active "Pro" subscriptions or sufficient AI tokens.
/// </summary>
public class ProUserRequirement : IAuthorizationRequirement
{
}
+36
View File
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Represents an E-book uploaded or owned by a user.
/// </summary>
public class Ebook
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(255)]
public string Title { get; set; } = string.Empty;
[MaxLength(255)]
public string Author { get; set; } = "Unknown";
[Required]
public string FilePath { get; set; } = string.Empty;
public string? CoverUrl { get; set; }
public DateTime AddedDate { get; set; } = DateTime.UtcNow;
public DateTime? LastReadDate { get; set; }
// Relationship to NexusUser
[Required]
public string UserId { get; set; } = string.Empty;
[ForeignKey(nameof(UserId))]
public NexusUser? User { get; set; }
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Identity;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Extended Identity user for the Nexus AI E-Reader SaaS platform.
/// </summary>
public class NexusUser : IdentityUser
{
/// <summary>
/// Total number of AI tokens allowed for the current billing period.
/// </summary>
public int AITokenLimit { get; set; }
/// <summary>
/// Number of AI tokens consumed in the current billing period.
/// </summary>
public int AITokensUsed { get; set; }
/// <summary>
/// Unique identifier for the tenant (SaaS multi-tenancy support).
/// </summary>
public Guid TenantId { get; set; }
/// <summary>
/// Current subscription plan (e.g., "Free", "Pro", "Enterprise").
/// </summary>
public string CurrentPlan { get; set; } = "Free";
/// <summary>
/// Collection of e-books owned by the user.
/// </summary>
public ICollection<Ebook> Ebooks { get; set; } = new List<Ebook>();
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
</ItemGroup>
</Project>
@@ -68,4 +68,42 @@ public sealed class MauiStorageService : INativeStorageService
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
{
return Result.Ok(await SecureStorage.Default.GetAsync(key));
}
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);
}
}
}
@@ -10,6 +10,10 @@ using NexusReader.Infrastructure.Services;
using NexusReader.Infrastructure.Configuration;
using Polly;
using Polly.Retry;
using NexusReader.Domain.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authorization;
using NexusReader.Application.Security.Authorization;
namespace NexusReader.Infrastructure;
@@ -53,6 +57,14 @@ public static class DependencyInjection
services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
services.AddTransient<IEpubService, EpubService>();
services.AddAuthorizationCore(options =>
{
options.AddPolicy("ProUser", policy => policy.Requirements.Add(new ProUserRequirement()));
});
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
return services;
}
}
@@ -0,0 +1,45 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Persistence;
namespace NexusReader.Infrastructure.Identity;
/// <summary>
/// Handler that validates if the user has available AI tokens.
/// </summary>
public class TokenLimitHandler : AuthorizationHandler<TokenLimitRequirement>
{
private readonly AppDbContext _dbContext;
private readonly UserManager<NexusUser> _userManager;
public TokenLimitHandler(AppDbContext dbContext, UserManager<NexusUser> userManager)
{
_dbContext = dbContext;
_userManager = userManager;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
TokenLimitRequirement requirement)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
{
return;
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return;
}
// Check if user has available tokens
if (user.AITokensUsed < user.AITokenLimit)
{
context.Succeed(requirement);
}
}
}
@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization;
namespace NexusReader.Infrastructure.Identity;
/// <summary>
/// Requirement to check if a user has not exceeded their AI token limit.
/// </summary>
public class TokenLimitRequirement : IAuthorizationRequirement
{
}
@@ -0,0 +1,368 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260428142027_InitialIdentityAndEbooks")]
partial class InitialIdentityAndEbooks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
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("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
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");
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");
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("TEXT");
b.Property<DateTime>("AddedDate")
.HasColumnType("TEXT");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
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("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<Guid>("TenantId")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
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(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("TEXT");
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,282 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialIdentityAndEbooks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
AITokenLimit = table.Column<int>(type: "INTEGER", nullable: false),
AITokensUsed = table.Column<int>(type: "INTEGER", nullable: false),
TenantId = table.Column<Guid>(type: "TEXT", nullable: false),
CurrentPlan = table.Column<string>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SemanticKnowledgeCache",
columns: table => new
{
ContentHash = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
JsonData = table.Column<string>(type: "TEXT", nullable: false),
ModelId = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
PromptVersion = table.Column<string>(type: "TEXT", maxLength: 10, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SemanticKnowledgeCache", x => x.ContentHash);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Ebooks",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
Author = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
FilePath = table.Column<string>(type: "TEXT", nullable: false),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
AddedDate = table.Column<DateTime>(type: "TEXT", nullable: false),
LastReadDate = table.Column<DateTime>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Ebooks", x => x.Id);
table.ForeignKey(
name: "FK_Ebooks_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Ebooks_UserId",
table: "Ebooks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_SemanticKnowledgeCache_ContentHash",
table: "SemanticKnowledgeCache",
column: "ContentHash",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "Ebooks");
migrationBuilder.DropTable(
name: "SemanticKnowledgeCache");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}
@@ -0,0 +1,365 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
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("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
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");
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");
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("TEXT");
b.Property<DateTime>("AddedDate")
.HasColumnType("TEXT");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
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("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<Guid>("TenantId")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
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(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("TEXT");
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
}
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
@@ -6,6 +6,8 @@
<ItemGroup>
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -16,12 +18,12 @@
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,15 +1,17 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NexusReader.Domain.Entities;
namespace NexusReader.Infrastructure.Persistence;
public class AppDbContext : DbContext
public class AppDbContext : IdentityDbContext<NexusUser>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>();
public DbSet<Ebook> Ebooks => Set<Ebook>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -20,5 +22,13 @@ public class AppDbContext : DbContext
entity.HasKey(e => e.ContentHash);
entity.HasIndex(e => e.ContentHash).IsUnique();
});
modelBuilder.Entity<Ebook>(entity =>
{
entity.HasOne(e => e.User)
.WithMany(u => u.Ebooks)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
+11
View File
@@ -28,6 +28,17 @@ public static class MauiProgram
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
// Identity
builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider>(sp =>
sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();
// Network
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://localhost:5000") }); // Update with real API URL later
// Shared UI State
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
@@ -7,6 +7,8 @@
@inject IFocusModeService FocusMode
@inject IQuizStateService QuizService
@inject IJSRuntime JS
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
@implements IDisposable
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
@@ -23,8 +25,23 @@
<IntelligenceToolbar />
<div class="intelligence-content">
<div class="intelligence-header">
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI i Interaktywna Mapa</span>
<div class="ai-title">
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span>
</div>
<AuthorizeView>
<Authorized>
<div class="user-profile">
<span class="user-email">@context.User.Identity?.Name</span>
<button class="logout-btn" @onclick="HandleLogout">Logout</button>
</div>
</Authorized>
<NotAuthorized>
<a href="/account/login" class="login-link">Login</a>
</NotAuthorized>
</AuthorizeView>
<button class="close-btn">×</button>
</div>
@@ -61,6 +78,12 @@
}
}
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/", true);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
<PackageReference Include="MediatR" Version="12.1.1" />
</ItemGroup>
@@ -0,0 +1,120 @@
@page "/account/login"
@using Microsoft.AspNetCore.Components.Forms
@using NexusReader.UI.Shared.Services
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>NEXUS<span>AI</span></h1>
<p>Welcome back, Reader.</p>
</div>
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
<DataAnnotationsValidator />
<div class="form-group">
<label for="email">Email</label>
<InputText id="email" @bind-Value="_loginModel.Email" class="form-control" placeholder="reader@nexus.ai" />
<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>
<a href="/account/forgot-password" class="forgot-link">Forgot password?</a>
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="error-banner">
@_errorMessage
</div>
}
<button type="submit" class="btn-login" disabled="@_isSubmitting">
@if (_isSubmitting)
{
<span class="spinner"></span>
}
else
{
<span>Login</span>
}
</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>
<div class="login-footer">
<p>Don't have an account? <a href="/account/register">Create one</a></p>
</div>
</div>
</div>
@code {
private LoginModel _loginModel = new();
private string? _errorMessage;
private bool _isSubmitting;
private async Task HandleLogin()
{
_isSubmitting = true;
_errorMessage = null;
try
{
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password);
if (success)
{
NavigationManager.NavigateTo("/");
}
else
{
_errorMessage = "Invalid email or password.";
}
}
catch (Exception)
{
_errorMessage = "An error occurred during login. Please try again.";
}
finally
{
_isSubmitting = false;
}
}
private void HandleGoogleLogin()
{
// Redirect to external login endpoint
NavigationManager.NavigateTo("identity/login/google", forceLoad: true);
}
public class LoginModel
{
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.EmailAddress]
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
}
}
@@ -0,0 +1,223 @@
.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;
}
@@ -0,0 +1,79 @@
@page "/account/profile"
@using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Services
@attribute [Authorize]
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
<div class="profile-container">
<div class="profile-card">
@if (_profile == null)
{
<div class="loading-state">
<div class="spinner"></div>
<p>Fetching your Nexus profile...</p>
</div>
}
else
{
<div class="profile-header">
<div class="user-avatar">
@(string.IsNullOrEmpty(_profile.Email) ? "?" : _profile.Email[0].ToString().ToUpper())
</div>
<h2>@_profile.Email</h2>
<div class="plan-badge @(_profile.CurrentPlan.ToLower())">
@_profile.CurrentPlan Plan
</div>
</div>
<div class="profile-stats">
<div class="stat-group">
<div class="stat-header">
<span>AI Token Usage</span>
<span class="usage-count">@_profile.AITokensUsed / @_profile.AITokenLimit</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: @(CalculateProgress())%"></div>
</div>
<p class="stat-footer">Resetting on your next billing date.</p>
</div>
</div>
<div class="profile-actions">
<button class="btn-primary" @onclick="HandleUpgrade">
Manage Subscription
</button>
<button class="btn-outline" @onclick="HandleLogout">
Sign Out
</button>
</div>
}
</div>
</div>
@code {
private UserProfile? _profile;
protected override async Task OnInitializedAsync()
{
_profile = await IdentityService.GetProfileAsync();
}
private int CalculateProgress()
{
if (_profile == null || _profile.AITokenLimit == 0) return 0;
var percent = (int)((double)_profile.AITokensUsed / _profile.AITokenLimit * 100);
return Math.Min(percent, 100);
}
private void HandleUpgrade()
{
// Future: Redirect to Stripe billing portal
}
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/account/login");
}
}
@@ -0,0 +1,173 @@
.profile-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #0a0a0a;
padding: 2rem;
}
.profile-card {
width: 100%;
max-width: 500px;
background: rgba(20, 20, 20, 0.8);
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 {
text-align: center;
margin-bottom: 3rem;
}
.user-avatar {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #39ff14 0%, #1a8a0a 100%);
border-radius: 50%;
margin: 0 auto 1.5rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
color: #000;
box-shadow: 0 0 20px rgba(57, 255, 20, 0.3);
}
.profile-header h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 0.75rem;
}
.plan-badge {
display: inline-block;
padding: 0.4rem 1rem;
border-radius: 2rem;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.plan-badge.free {
background: rgba(255, 255, 255, 0.1);
color: #888;
}
.plan-badge.pro {
background: rgba(57, 255, 20, 0.1);
color: #39ff14;
border: 1px solid rgba(57, 255, 20, 0.2);
}
.profile-stats {
margin-bottom: 3rem;
}
.stat-group {
background: rgba(255, 255, 255, 0.03);
padding: 1.5rem;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.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;
cursor: pointer;
transition: all 0.2s;
}
.btn-outline:hover {
background: rgba(255, 85, 85, 0.05);
border-color: rgba(255, 85, 85, 0.4);
}
.loading-state {
text-align: center;
padding: 4rem 0;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(57, 255, 20, 0.1);
border-top-color: #39ff14;
border-radius: 50%;
margin: 0 auto 1.5rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,112 @@
@page "/account/register"
@using Microsoft.AspNetCore.Components.Forms
@using NexusReader.UI.Shared.Services
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>NEXUS<span>AI</span></h1>
<p>Join the future of reading.</p>
</div>
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
<DataAnnotationsValidator />
<div class="form-group">
<label for="email">Email</label>
<InputText id="email" @bind-Value="_registerModel.Email" class="form-control" placeholder="reader@nexus.ai" />
<ValidationMessage For="@(() => _registerModel.Email)" />
</div>
<div class="form-group">
<label for="password">Password</label>
<InputText id="password" type="password" @bind-Value="_registerModel.Password" class="form-control" placeholder="Min 8 chars, uppercase, digit" />
<ValidationMessage For="@(() => _registerModel.Password)" />
</div>
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<InputText id="confirm-password" type="password" @bind-Value="_registerModel.ConfirmPassword" class="form-control" placeholder="••••••••" />
<ValidationMessage For="@(() => _registerModel.ConfirmPassword)" />
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="error-banner">
@_errorMessage
</div>
}
<button type="submit" class="btn-login" disabled="@_isSubmitting">
@if (_isSubmitting)
{
<span class="spinner"></span>
}
else
{
<span>Register</span>
}
</button>
</EditForm>
<div class="login-footer">
<p>Already have an account? <a href="/account/login">Login here</a></p>
</div>
</div>
</div>
@code {
private RegisterModel _registerModel = new();
private string? _errorMessage;
private bool _isSubmitting;
private async Task HandleRegister()
{
if (_registerModel.Password != _registerModel.ConfirmPassword)
{
_errorMessage = "Passwords do not match.";
return;
}
_isSubmitting = true;
_errorMessage = null;
try
{
var success = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password);
if (success)
{
// Registration successful, redirect to login
NavigationManager.NavigateTo("/account/login?registered=true");
}
else
{
_errorMessage = "Registration failed. Email might already be in use.";
}
}
catch (Exception)
{
_errorMessage = "An error occurred during registration. Please try again.";
}
finally
{
_isSubmitting = false;
}
}
public class RegisterModel
{
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.EmailAddress]
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.MinLength(8)]
public string Password { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
public string ConfirmPassword { get; set; } = string.Empty;
}
}
@@ -0,0 +1,174 @@
.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); }
}
+5 -1
View File
@@ -2,7 +2,11 @@
<ChildContent>
<Router AppAssembly="@typeof(Routes).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)">
<NotAuthorized>
<p role="alert">You are not authorized to access this resource. Please login.</p>
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,87 @@
using System.Net.Http.Json;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.UI.Shared.Services;
public interface IIdentityService
{
Task<bool> RegisterAsync(string email, string password);
Task<bool> LoginAsync(string email, string password);
Task LogoutAsync();
Task<UserProfile?> GetProfileAsync();
}
public record UserProfile(
string Email,
int AITokenLimit,
int AITokensUsed,
string CurrentPlan,
Guid TenantId);
public class IdentityService : IIdentityService
{
private readonly HttpClient _httpClient;
private readonly INativeStorageService _storageService;
private readonly NexusAuthenticationStateProvider _authStateProvider;
private const string TokenKey = "nexus_auth_token";
public IdentityService(
HttpClient httpClient,
INativeStorageService storageService,
NexusAuthenticationStateProvider authStateProvider)
{
_httpClient = httpClient;
_storageService = storageService;
_authStateProvider = authStateProvider;
}
public async Task<bool> RegisterAsync(string email, string password)
{
var response = await _httpClient.PostAsJsonAsync("identity/register", new { email, password });
return response.IsSuccessStatusCode;
}
public async Task<bool> LoginAsync(string email, string password)
{
var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password });
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<LoginResponse>();
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
{
await _storageService.SaveSecureString(TokenKey, result.AccessToken);
_authStateProvider.NotifyUserAuthentication(result.AccessToken);
return true;
}
}
return false;
}
public async Task LogoutAsync()
{
_storageService.RemoveSecure(TokenKey);
_authStateProvider.NotifyUserLogout();
}
public async Task<UserProfile?> GetProfileAsync()
{
try
{
return await _httpClient.GetFromJsonAsync<UserProfile>("identity/profile");
}
catch
{
return null;
}
}
private class LoginResponse
{
public string TokenType { get; set; } = string.Empty;
public string AccessToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; }
public string RefreshToken { get; set; } = string.Empty;
}
}
@@ -0,0 +1,81 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.UI.Shared.Services;
public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly INativeStorageService _storageService;
private const string TokenKey = "nexus_auth_token";
public NexusAuthenticationStateProvider(INativeStorageService storageService)
{
_storageService = storageService;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var result = await _storageService.GetSecureString(TokenKey);
var token = result.IsSuccess ? result.Value : null;
if (string.IsNullOrWhiteSpace(token))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
var user = new ClaimsPrincipal(identity);
return new AuthenticationState(user);
}
catch (Exception)
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
}
public void NotifyUserAuthentication(string token)
{
var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
var user = new ClaimsPrincipal(identity);
var authState = Task.FromResult(new AuthenticationState(user));
NotifyAuthenticationStateChanged(authState);
}
public void NotifyUserLogout()
{
var guest = new ClaimsPrincipal(new ClaimsIdentity());
var authState = Task.FromResult(new AuthenticationState(guest));
NotifyAuthenticationStateChanged(authState);
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var claims = new List<Claim>();
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
if (keyValuePairs != null)
{
claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString() ?? string.Empty)));
}
return claims;
}
private byte[] ParseBase64WithoutPadding(string base64)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}
+1
View File
@@ -3,6 +3,7 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Authorization
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@@ -11,7 +11,7 @@
<ItemGroup>
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NexusReader.Domain.Entities;
using Stripe;
namespace NexusReader.Web.New.Controllers;
[Route("api/[controller]")]
[ApiController]
public class StripeWebhookController : ControllerBase
{
private readonly UserManager<NexusUser> _userManager;
private readonly IConfiguration _configuration;
private readonly string _webhookSecret;
public StripeWebhookController(UserManager<NexusUser> userManager, IConfiguration configuration)
{
_userManager = userManager;
_configuration = configuration;
_webhookSecret = _configuration["Stripe:WebhookSecret"] ?? "";
}
[HttpPost]
public async Task<IActionResult> Index()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
try
{
var stripeEvent = EventUtility.ConstructEvent(
json,
Request.Headers["Stripe-Signature"],
_webhookSecret
);
switch (stripeEvent.Type)
{
case EventTypes.CheckoutSessionCompleted:
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata);
break;
case EventTypes.CustomerSubscriptionUpdated:
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
// Subscription update might not have email directly, would need to fetch customer
// For now, assuming email is in metadata if we set it during checkout
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata);
break;
case EventTypes.CustomerSubscriptionDeleted:
var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"]);
break;
}
return Ok();
}
catch (StripeException e)
{
return BadRequest(e.Message);
}
}
private async Task HandleSubscriptionSuccess(string? email, Dictionary<string, string>? metadata)
{
if (string.IsNullOrEmpty(email)) return;
var user = await _userManager.FindByEmailAsync(email);
if (user != null)
{
var plan = metadata != null && metadata.ContainsKey("Plan") ? metadata["Plan"] : "Pro";
user.CurrentPlan = plan;
user.AITokenLimit = plan.ToLower() switch
{
"pro" => 50000,
"enterprise" => 500000,
_ => 10000 // default for unknown or free
};
await _userManager.UpdateAsync(user);
}
}
private async Task HandleSubscriptionCancellation(string? email)
{
if (string.IsNullOrEmpty(email)) return;
var user = await _userManager.FindByEmailAsync(email);
if (user != null)
{
user.CurrentPlan = "Free";
user.AITokenLimit = 5000; // Free tier limit
await _userManager.UpdateAsync(user);
}
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -9,8 +9,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Stripe.net" Version="51.1.0" />
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
+139 -1
View File
@@ -3,7 +3,17 @@ using NexusReader.Application;
using NexusReader.Infrastructure;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services;
using NexusReader.Web.New.Services;
using NexusReader.UI.Shared.Services;
using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using NexusReader.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
@@ -12,6 +22,8 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddControllers();
// Enable detailed circuit errors for ServerSide Blazor components
builder.Services.AddServerSideBlazor()
.AddCircuitOptions(options =>
@@ -20,6 +32,7 @@ builder.Services.AddServerSideBlazor()
});
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
@@ -28,16 +41,77 @@ builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddHttpClient("NexusAPI", client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000");
});
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// Authorization Policies
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise"));
options.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
});
// Authentication
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id";
options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "placeholder-secret";
});
builder.Services.AddIdentityApiEndpoints<NexusUser>()
.AddEntityFrameworkStores<AppDbContext>();
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
});
builder.Services.Configure<IdentityOptions>(options =>
{
// Password settings
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireUppercase = true;
options.Password.RequiredLength = 8;
options.Password.RequiredUniqueChars = 1;
// Lockout settings
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
// User settings
options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
options.User.RequireUniqueEmail = true;
});
var app = builder.Build();
// Ensure Database is initialized
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<NexusReader.Infrastructure.Persistence.AppDbContext>();
dbContext.Database.EnsureCreated();
await dbContext.Database.MigrateAsync();
}
// Configure the HTTP request pipeline.
@@ -58,7 +132,10 @@ if (!app.Environment.IsDevelopment())
}
app.UseAntiforgery();
app.UseAuthentication();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllers();
// API endpoint for WASM client to fetch EPUB content
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
@@ -100,6 +177,67 @@ app.MapDelete("/api/knowledge", async (IKnowledgeService knowledgeService) =>
return Results.BadRequest(errorMsg);
});
app.MapGroup("/identity").MapIdentityApi<NexusUser>();
app.MapGet("/identity/login/google", (string? returnUrl) =>
{
var properties = new AuthenticationProperties
{
RedirectUri = "/identity/callback/google",
Items = { { "returnUrl", returnUrl ?? "/" } }
};
return Results.Challenge(properties, new[] { "Google" });
});
app.MapGet("/identity/callback/google", async (
HttpContext context,
SignInManager<NexusUser> signInManager,
UserManager<NexusUser> userManager) =>
{
var info = await signInManager.GetExternalLoginInfoAsync();
if (info == null) return Results.Redirect("/account/login?error=ExternalLoginFailed");
var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
if (result.Succeeded)
{
return Results.Redirect("/");
}
// New user provisioning
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
if (email != null)
{
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
var createResult = await userManager.CreateAsync(user);
if (createResult.Succeeded)
{
await userManager.AddLoginAsync(user, info);
await signInManager.SignInAsync(user, isPersistent: false);
return Results.Redirect("/");
}
}
return Results.Redirect("/account/login?error=ProvisioningFailed");
});
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Results.Unauthorized();
var nexusUser = await userManager.FindByIdAsync(userId);
if (nexusUser == null) return Results.NotFound();
return Results.Ok(new
{
nexusUser.Email,
nexusUser.AITokenLimit,
nexusUser.AITokensUsed,
nexusUser.CurrentPlan,
nexusUser.TenantId
});
}).RequireAuthorization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
@@ -0,0 +1,91 @@
using FluentResults;
using Microsoft.JSInterop;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.New.Services;
public class WebStorageService : INativeStorageService
{
private readonly IJSRuntime _jsRuntime;
public WebStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public Result SaveString(string key, string value)
{
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);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
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.");
}
public Result SaveBool(string key, bool value) => SaveString(key, value.ToString());
public Result<bool> GetBool(string key, bool defaultValue = false)
{
return Result.Ok(defaultValue);
}
public Result Remove(string key)
{
try
{
_jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result> SaveSecureString(string key, string value)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result<string?>> GetSecureString(string key)
{
try
{
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
return Result.Ok(value);
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result RemoveSecure(string key)
{
return Remove(key);
}
}
+6
View File
@@ -9,6 +9,12 @@
"ConnectionStrings": {
"SqliteConnection": "Data Source=nexus.db"
},
"Authentication": {
"Google": {
"ClientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"ClientSecret": "YOUR_CLIENT_SECRET"
}
},
"Ai": {
"Google": {
"ApiKey": "PLACEHOLDER",
Binary file not shown.
Binary file not shown.