Refactor: Web Consolidation and Identity Stabilization (#40)
## Overview This PR completes the architectural consolidation of the web project and stabilizes the Identity-based authentication flow for the NexusReader application. It also refines the UI aesthetic for the Book Ingestion Modal as requested in #33. ## Key Changes - **Project Consolidation**: Fully merged `NexusReader.Web.New` into `NexusReader.Web`. This includes updating all namespace references, VS Code launch/task configurations, and CI/CD (`Dockerfile`). - **Identity Stabilization**: - Implemented `IIdentityService` on the server using `SignInManager<NexusUser>` and `UserManager<NexusUser>`. - Fixed registration logic to include mandatory fields (`SubscriptionPlanId`, `TenantId`). - Updated `Login.razor` to force a page reload on successful login, ensuring proper synchronization of authentication cookies between SignalR and the browser. - **UI/UX Refinement**: - Updated `BookIngestionModal` styling to follow the **Nexus Neon** design system. - Added premium button styles with hover effects and glows. - Improved modal layout and interaction feedback (shimmer effects, spinner colors). - **Cleanup**: Removed obsolete interfaces and constants that were superseded by newer Application layer implementations. ## Verification - Successfully built the solution: `dotnet build NexusReader.slnx --no-restore` - Verified project structure and file moves. - Validated server-side authentication logic. Fixes #33 --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #40 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #40.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
||||
<link rel="stylesheet" href="NexusReader.Web.styles.css" />
|
||||
<ImportMap />
|
||||
|
||||
<HeadOutlet @rendermode="InteractiveAuto" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app-preloader">
|
||||
<div class="preloader-spinner"></div>
|
||||
<div class="preloader-text">Nexus Reader</div>
|
||||
</div>
|
||||
|
||||
<NexusReader.UI.Shared.Routes @rendermode="InteractiveAuto" />
|
||||
<ReconnectModal />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
function hidePreloader() {
|
||||
const preloader = document.getElementById('app-preloader');
|
||||
if (preloader) {
|
||||
preloader.classList.add('loaded');
|
||||
// Completely remove from DOM after transition for better accessibility
|
||||
setTimeout(() => preloader.style.display = 'none', 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
hidePreloader();
|
||||
} else {
|
||||
window.addEventListener('load', hidePreloader);
|
||||
}
|
||||
|
||||
// Fallback: If for some reason 'load' doesn't fire (e.g. big assets), hide after 3s anyway
|
||||
setTimeout(hidePreloader, 3000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
@code {
|
||||
[Parameter]
|
||||
public Exception? Exception { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// You could log the exception here using a logging service.
|
||||
}
|
||||
}
|
||||
|
||||
<h3 class="text-danger">An unexpected error occurred.</h3>
|
||||
<p>Please try reloading the page. If the problem persists, contact support.</p>
|
||||
@if (Exception != null)
|
||||
{
|
||||
<details class="mt-2">
|
||||
<summary>Technical details (click to expand)</summary>
|
||||
<pre style="white-space: pre-wrap; word-break: break-all;">@Exception.ToString()</pre>
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using NexusReader.Web
|
||||
@using NexusReader.UI.Shared
|
||||
@using NexusReader.UI.Shared.Layout
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using NexusReader.UI.Shared.Components.Molecules
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
<UserSecretsId>154e0538-f82d-4ec0-81e7-c1ff39d6d919</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Stripe.net" Version="51.1.0" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
|
||||
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,484 @@
|
||||
using NexusReader.Web.Components;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NexusReader.Application;
|
||||
using NexusReader.Infrastructure;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.User;
|
||||
using MediatR;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using NexusReader.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using System.Security.Claims;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using Stripe;
|
||||
|
||||
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents()
|
||||
.AddInteractiveWebAssemblyComponents();
|
||||
|
||||
// Enable detailed circuit errors for Server‑Side Blazor components
|
||||
builder.Services.AddServerSideBlazor()
|
||||
.AddCircuitOptions(options =>
|
||||
{
|
||||
options.DetailedErrors = true;
|
||||
});
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
|
||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000");
|
||||
});
|
||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<IIdentityService, NexusReader.Web.Services.ServerIdentityService>();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||
NexusReader.Application.DependencyInjection.Assembly,
|
||||
NexusReader.Infrastructure.DependencyInjection.Assembly
|
||||
));
|
||||
|
||||
// Authorization Policies
|
||||
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName))
|
||||
.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
|
||||
|
||||
// Billing & Stripe
|
||||
builder.Services.AddScoped<IBillingService, NexusReader.Infrastructure.Services.BillingService>();
|
||||
|
||||
// Authentication
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
||||
})
|
||||
.AddGoogle(options =>
|
||||
{
|
||||
options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id";
|
||||
options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "placeholder-secret";
|
||||
});
|
||||
|
||||
builder.Services.AddIdentityApiEndpoints<NexusUser>()
|
||||
.AddRoles<IdentityRole>()
|
||||
.AddEntityFrameworkStores<AppDbContext>();
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/account/login";
|
||||
options.LogoutPath = "/account/logout";
|
||||
options.AccessDeniedPath = "/account/access-denied";
|
||||
options.Cookie.Name = "NexusReader.Auth";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
|
||||
options.Events.OnRedirectToLogin = context =>
|
||||
{
|
||||
var isApiRequest = context.Request.Path.StartsWithSegments("/api") ||
|
||||
context.Request.Path.StartsWithSegments("/identity") ||
|
||||
context.Request.Headers["Accept"].ToString().Contains("application/json");
|
||||
|
||||
if (isApiRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Response.Redirect(context.RedirectUri);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.Configure<IdentityOptions>(options =>
|
||||
{
|
||||
// Password settings
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireNonAlphanumeric = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequiredLength = 8;
|
||||
options.Password.RequiredUniqueChars = 1;
|
||||
|
||||
// Lockout settings
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
|
||||
options.Lockout.MaxFailedAccessAttempts = 5;
|
||||
options.Lockout.AllowedForNewUsers = true;
|
||||
|
||||
// User settings
|
||||
options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
|
||||
options.User.RequireUniqueEmail = true;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Startup Validation
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var marker = scope.ServiceProvider.GetService<IInfrastructureMarker>();
|
||||
if (marker == null)
|
||||
{
|
||||
throw new InvalidOperationException("CRITICAL: Infrastructure layer was not registered. Ensure AddInfrastructure() is called in Program.cs.");
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Database is initialized and seeded
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider;
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
var dbContextFactory = services.GetRequiredService<IDbContextFactory<NexusReader.Data.Persistence.AppDbContext>>();
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
int maxRetries = 5;
|
||||
int delayMs = 2000;
|
||||
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Próba połączenia z bazą danych (próba {Attempt}/{MaxRetries})...", i + 1, maxRetries);
|
||||
}
|
||||
|
||||
await dbContext.Database.MigrateAsync();
|
||||
await DbInitializer.SeedAsync(services);
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Baza danych zainicjowana pomyślnie.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
catch (Npgsql.NpgsqlException ex) when (i < maxRetries - 1)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
logger.LogWarning(ex, "Błąd połączenia z bazą danych. Ponowna próba za {Delay}ms...", delayMs);
|
||||
}
|
||||
|
||||
await Task.Delay(delayMs);
|
||||
delayMs *= 2; // Exponential backoff
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Critical))
|
||||
{
|
||||
logger.LogCritical(ex, "Krytyczny błąd podczas inicjalizacji bazy danych.");
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseWebAssemblyDebugging();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
app.UseAntiforgery();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapStaticAssets();
|
||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||
|
||||
// API endpoint for WASM client to fetch EPUB content
|
||||
app.MapGet("/api/epub/{index}", async (int index, IEpubReader epubService, ClaimsPrincipal user) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var result = await epubService.GetEpubContentAsync(index, userId);
|
||||
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens");
|
||||
|
||||
knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
|
||||
{
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId);
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||
});
|
||||
|
||||
knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
|
||||
{
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId);
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||
});
|
||||
|
||||
knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
|
||||
{
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId);
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||
});
|
||||
|
||||
knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
|
||||
{
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
var result = await knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, tenantId);
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||
});
|
||||
|
||||
knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
|
||||
{
|
||||
var result = await knowledgeService.ClearCacheAsync();
|
||||
if (result.IsSuccess) return Results.Ok();
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||
return Results.BadRequest(errorMsg);
|
||||
});
|
||||
|
||||
app.MapPost("/api/StripeWebhook", async (
|
||||
HttpContext context,
|
||||
UserManager<NexusUser> userManager,
|
||||
IConfiguration configuration,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||
{
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
var json = await new StreamReader(context.Request.Body).ReadToEndAsync();
|
||||
var webhookSecret = configuration["Stripe:WebhookSecret"] ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
var stripeEvent = EventUtility.ConstructEvent(
|
||||
json,
|
||||
context.Request.Headers["Stripe-Signature"],
|
||||
webhookSecret
|
||||
);
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case EventTypes.CheckoutSessionCompleted:
|
||||
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
||||
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager, dbContext);
|
||||
break;
|
||||
|
||||
case EventTypes.CustomerSubscriptionUpdated:
|
||||
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager, dbContext);
|
||||
break;
|
||||
|
||||
case EventTypes.CustomerSubscriptionDeleted:
|
||||
var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager, dbContext);
|
||||
break;
|
||||
}
|
||||
return Results.Ok();
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
return Results.BadRequest(e.Message);
|
||||
}
|
||||
});
|
||||
|
||||
async Task HandleSubscriptionSuccess(
|
||||
string? email,
|
||||
Dictionary<string, string>? metadata,
|
||||
UserManager<NexusUser> userManager,
|
||||
AppDbContext dbContext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email)) return;
|
||||
|
||||
var user = await userManager.FindByEmailAsync(email);
|
||||
if (user != null)
|
||||
{
|
||||
var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName;
|
||||
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName);
|
||||
|
||||
if (plan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = plan.Id;
|
||||
user.AITokenLimit = plan.AITokenLimit;
|
||||
}
|
||||
|
||||
await userManager.UpdateAsync(user);
|
||||
}
|
||||
}
|
||||
|
||||
async Task HandleSubscriptionCancellation(
|
||||
string? email,
|
||||
UserManager<NexusUser> userManager,
|
||||
AppDbContext dbContext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email)) return;
|
||||
|
||||
var user = await userManager.FindByEmailAsync(email);
|
||||
if (user != null)
|
||||
{
|
||||
var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId);
|
||||
user.SubscriptionPlanId = SubscriptionPlan.FreeId;
|
||||
user.AITokenLimit = freePlan?.AITokenLimit ?? 5000;
|
||||
await userManager.UpdateAsync(user);
|
||||
}
|
||||
}
|
||||
|
||||
app.MapGroup("/identity").MapIdentityApi<NexusUser>();
|
||||
|
||||
app.MapGet("/identity/login/google", (string? returnUrl) =>
|
||||
{
|
||||
var properties = new AuthenticationProperties
|
||||
{
|
||||
RedirectUri = "/identity/callback/google",
|
||||
Items = { { "returnUrl", returnUrl ?? "/" } }
|
||||
};
|
||||
return Results.Challenge(properties, new[] { "Google" });
|
||||
});
|
||||
|
||||
app.MapGet("/identity/callback/google", async (
|
||||
HttpContext context,
|
||||
SignInManager<NexusUser> signInManager,
|
||||
UserManager<NexusUser> userManager,
|
||||
ILogger<Program> logger) =>
|
||||
{
|
||||
var info = await signInManager.GetExternalLoginInfoAsync();
|
||||
if (info == null)
|
||||
{
|
||||
logger.LogWarning("External login info from Google is null.");
|
||||
return Results.Redirect("/account/login?error=ExternalLoginFailed");
|
||||
}
|
||||
|
||||
var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
logger.LogInformation("User logged in via Google: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email));
|
||||
return Results.Redirect("/");
|
||||
}
|
||||
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
logger.LogWarning("User account locked out during Google login: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email));
|
||||
return Results.Redirect("/account/login?error=LockedOut");
|
||||
}
|
||||
|
||||
// New user provisioning
|
||||
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
|
||||
if (email != null)
|
||||
{
|
||||
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
|
||||
var createResult = await userManager.CreateAsync(user);
|
||||
|
||||
if (createResult.Succeeded)
|
||||
{
|
||||
await userManager.AddLoginAsync(user, info);
|
||||
await signInManager.SignInAsync(user, isPersistent: false);
|
||||
logger.LogInformation("New user provisioned via Google: {Email}", email);
|
||||
return Results.Redirect("/");
|
||||
}
|
||||
|
||||
// Log specific errors
|
||||
foreach (var error in createResult.Errors)
|
||||
{
|
||||
logger.LogError("Google provisioning failed for {Email}: {Code} - {Description}", email, error.Code, error.Description);
|
||||
}
|
||||
|
||||
if (createResult.Errors.Any(e => e.Code == "DuplicateEmail" || e.Code == "DuplicateUserName"))
|
||||
{
|
||||
return Results.Redirect("/account/login?error=UserAlreadyExists");
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogError("Google provisioning failed - unknown reason for email {Email}", email);
|
||||
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
||||
});
|
||||
|
||||
app.MapPost("/account/login-form", async (
|
||||
[FromForm] string email,
|
||||
[FromForm] string password,
|
||||
[FromForm] bool rememberMe,
|
||||
[FromForm] string? returnUrl,
|
||||
SignInManager<NexusUser> signInManager,
|
||||
ILogger<Program> logger) =>
|
||||
{
|
||||
var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
logger.LogInformation("User logged in: {Email}", email);
|
||||
return Results.Redirect(returnUrl ?? "/");
|
||||
}
|
||||
|
||||
var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials";
|
||||
return Results.Redirect($"/account/login?error={error}");
|
||||
}).DisableAntiforgery(); // Simplified for now, in production use proper antiforgery
|
||||
|
||||
app.MapGet("/account/logout-form", async (
|
||||
SignInManager<NexusUser> signInManager,
|
||||
ILogger<Program> logger) =>
|
||||
{
|
||||
await signInManager.SignOutAsync();
|
||||
logger.LogInformation("User logged out via form.");
|
||||
return Results.Redirect("/account/login");
|
||||
});
|
||||
|
||||
app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId == null) return Results.Unauthorized();
|
||||
|
||||
var result = await mediator.Send(new GetUserProfileQuery(userId));
|
||||
if (result.IsFailed) return Results.NotFound(result.Errors.FirstOrDefault()?.Message);
|
||||
|
||||
return Results.Ok(result.Value);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly);
|
||||
|
||||
app.Run();
|
||||
|
||||
public record KnowledgeRequest(string Text);
|
||||
public record GroundednessRequest(string Answer, string Context);
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "http://localhost:5104",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "https://localhost:7131;http://localhost:5104",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using FluentResults;
|
||||
using Microsoft.JSInterop;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side implementation of INativeStorageService.
|
||||
/// Note: In Blazor Server, localStorage is only accessible via JS Interop.
|
||||
/// This implementation handles cases where JS Interop might not be available (e.g. during prerendering).
|
||||
/// </summary>
|
||||
public class NativeStorageService : INativeStorageService
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
|
||||
public NativeStorageService(IJSRuntime jsRuntime)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task<Result> SaveStringAsync(string key, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<string?>> GetStringAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
|
||||
return Result.Ok(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Result> SaveBoolAsync(string key, bool value) => SaveStringAsync(key, value.ToString());
|
||||
|
||||
public async Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
|
||||
if (string.IsNullOrEmpty(value)) return Result.Ok(defaultValue);
|
||||
return Result.Ok(bool.TryParse(value, out var result) ? result : defaultValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Result.Ok(defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> RemoveAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> SaveSecureString(string key, string value) => await SaveStringAsync(key, value);
|
||||
|
||||
public async Task<Result<string?>> GetSecureString(string key) => await GetStringAsync(key);
|
||||
|
||||
public Task<Result> RemoveSecureAsync(string key) => RemoveAsync(key);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Security.Claims;
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.Queries.User;
|
||||
using MediatR;
|
||||
using NexusReader.Application.Constants;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Web.Services;
|
||||
|
||||
public class ServerIdentityService : IIdentityService
|
||||
{
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
private readonly SignInManager<NexusUser> _signInManager;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly INativeStorageService _storageService;
|
||||
|
||||
public event Func<Task>? OnStateInvalidated;
|
||||
|
||||
public ServerIdentityService(
|
||||
UserManager<NexusUser> userManager,
|
||||
SignInManager<NexusUser> signInManager,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IMediator mediator,
|
||||
INativeStorageService storageService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_mediator = mediator;
|
||||
_storageService = storageService;
|
||||
}
|
||||
|
||||
public async Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user == null) return Result.Fail("Nieprawidłowy e-mail lub hasło.");
|
||||
|
||||
// Check if account is locked
|
||||
if (await _userManager.IsLockedOutAsync(user)) return Result.Fail("Konto zostało zablokowane.");
|
||||
|
||||
// Check password
|
||||
var isCorrect = await _userManager.CheckPasswordAsync(user, password);
|
||||
if (!isCorrect)
|
||||
{
|
||||
await _userManager.AccessFailedAsync(user);
|
||||
return Result.Fail("Nieprawidłowy e-mail lub hasło.");
|
||||
}
|
||||
|
||||
// Reset access failed count on success
|
||||
await _userManager.ResetAccessFailedCountAsync(user);
|
||||
|
||||
// In Blazor Interactive Server, we cannot use PasswordSignInAsync directly
|
||||
// because headers are read-only once the circuit is established.
|
||||
// We return success here to indicate credentials are valid.
|
||||
// The UI will then perform a POST redirect to /account/login-form to set cookies.
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Błąd podczas weryfikacji poświadczeń: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> LogoutAsync()
|
||||
{
|
||||
// Logout via SignalR is also problematic for cookie clearing.
|
||||
// The UI should redirect to /account/logout-form
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
public async Task<Result> RegisterAsync(string email, string password)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = new NexusUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
SubscriptionPlanId = SubscriptionPlan.FreeId,
|
||||
TenantId = "global"
|
||||
};
|
||||
var result = await _userManager.CreateAsync(user, password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
// Similar to Login, we return success but don't sign in here.
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Rejestracja nie powiodła się.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Błąd podczas rejestracji na serwerze: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Result> RefreshTokenAsync() => Task.FromResult(Result.Ok());
|
||||
|
||||
public async Task<Result<UserProfileDto>> GetProfileAsync()
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User;
|
||||
if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated.");
|
||||
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId == null) return Result.Fail("User ID not found.");
|
||||
|
||||
var result = await _mediator.Send(new GetUserProfileQuery(userId));
|
||||
if (result.IsFailed) return Result.Fail(result.Errors);
|
||||
|
||||
return Result.Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"Stripe": {
|
||||
"ApiKey": "sk_test_placeholder",
|
||||
"WebhookSecret": "whsec_placeholder",
|
||||
"ProProductId": "prod_Pro123",
|
||||
"BasicProductId": "prod_Basic456",
|
||||
"FreeProductId": "prod_Free789"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"SqliteConnection": "Data Source=nexus.db",
|
||||
"PostgresConnection": "Host=localhost;Database=nexus_db;Username=nexus_user;Password=nexus_password"
|
||||
},
|
||||
"Authentication": {
|
||||
"Google": {
|
||||
"ClientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
||||
"ClientSecret": "YOUR_CLIENT_SECRET"
|
||||
}
|
||||
},
|
||||
"Ai": {
|
||||
"Google": {
|
||||
"ApiKey": "PLACEHOLDER",
|
||||
"Model": "gemini-2.5-flash-lite",
|
||||
"MaxInputTokens": 15000,
|
||||
"MaxOutputTokens": 8192
|
||||
}
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5000"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
--nexus-neon: #00ff99;
|
||||
--nexus-bg: #121212;
|
||||
--nexus-card: #1e1e1e;
|
||||
--nexus-text: #ffffff;
|
||||
--nexus-font-sans: 'Inter', sans-serif;
|
||||
--nexus-font-serif: 'Merriweather', serif;
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--nexus-bg: #F5F5F5;
|
||||
--nexus-card: #FFFFFF;
|
||||
--nexus-text: #1A1A1A;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--nexus-bg);
|
||||
color: var(--nexus-text);
|
||||
font-family: var(--nexus-font-sans);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
Reference in New Issue
Block a user