refactor: consolidate project structure by migrating authentication, identity, and shared UI components while removing legacy Web Client files.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 Server‑Side 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user